Pulse Platform API Reference

Download OpenAPI specification

openapi: 3.1.0
x-computer-health-contract: /api/v1/computers/openapi.json
info:
  title: CoverCore Platform API
  version: 1.0.0
  description: |
    Public partner API for the CoverCore (Pulse) insurance operating system.

    This spec covers the StudioGuard launch lifecycle plus the supporting
    foundation (auth, jobs, webhooks). Lifecycle order:

    **Score → Evaluate → Rate → Quote → Bind → BRDX → Commission**

    For non-lifecycle endpoints (contacts, opportunities, campaigns, etc.)
    see the human-readable reference at `/api/v1/docs`.

    Authentication is by partner API key. Sandbox keys (`pk_test_*`) read
    from a deterministic synthetic dataset; live keys (`pk_live_*`) read
    production data. See the sandbox quickstart in the reference doc for
    a curl-only walkthrough that takes a partner from key issuance to a
    bound policy in 8 commands.

    API credentials are disclosed exactly once, in the successful creation
    response. Existing credentials cannot be recovered from CoverCore or any
    metadata endpoint; create a replacement if the one-time value was not
    stored.

    **Agent credential contract.** Keys issued from an agent's CoverCore
    session are immutable, agent-scoped credentials. Their closed scope set is
    `policies:read`, `policies:write`, `quotes:read`, `quotes:write`, `contacts:read`,
    `documents:read`, `documents:write`, `search:read`, `commission:read`,
    `commission:write`, `service_requests:read`, `service_requests:write`, and
    `webhooks:manage`. The server also enforces a closed route/method matrix, so
    an old key containing a broader scope still cannot reach unsupported
    endpoints.

    Agent access is limited to records carrying direct evidence for the
    credential's immutable CoverCore user/contact/FileMaker identity. Agency
    membership never expands this authority to a sibling agent. Missing,
    ambiguous, or mismatched linkage fails closed. Webhook subscriptions and
    delivery logs belong to one API key, and tenant-bearing events are
    delivered only when ownership can be proven for that key.

    **Scopes** are grouped into thirteen categories — the same categories,
    order, and scope names shown in the API Keys editor
    (Settings → Developer → API Keys → Edit), the developer landing page,
    and the reference doc. `/health` is public. `/me` requires authentication.
    `/jobs/:id` is unavailable to agent-scoped keys unless it is added to the
    closed agent capability matrix in a future API version.

    1. **Core Insurance**: `policies:read`, `policies:write`, `quotes:read`, `quotes:write`, `claims:read`, `claims:write`, `carriers:read`, `agencies:read`, `agencies:read_pii`, `agencies:write`, `licenses:read`, `documents:read`, `documents:write`, `prevention:read`, `trim:read`, `trim:write`
    2. **Core Warranty**: `warranty:rate`, `warranty:lookup`, `warranty:quote`, `warranty:test`, `warranty:audit`
    3. **Sales & Growth**: `contacts:read`, `contacts:write`, `opportunities:read`, `opportunities:write`, `campaigns:read`, `campaigns:write`, `thumbtack:read`, `thumbtack:write`, `search:read`, `hubspot:read`, `hubspot:sync`
    4. **Intelligence**: `analytics:read`, `analytics:write`, `social_intel:read`, `social_intel:write`, `social_queue:read`, `social_queue:write`, `publisher:read`, `publisher:write`, `enrichment:read`, `intel_feed:read`, `intel_feed:write`
    5. **Operations**: `emails:read`, `emails:write`, `email_inbox:read`, `email_inbox:write`, `interactions:read`, `interactions:write`, `utm:read`, `utm:write`, `tasks:read`, `tasks:write`, `service_requests:read`, `service_requests:write`, `agents:read`, `partnerships:read`, `chat_escalations:read`, `chat_escalations:write`, `email_stats:read`, `staff:read`
    6. **Compliance & Finance**: `filings:read`, `filings:write`, `billing:read`, `billing:write`, `webhooks:manage`, `zoe:read`
    7. **AI Learning**: `corrections:read`, `corrections:write`
    8. **AI Goals**: `ai_goals:read`, `ai_goals:write`, `ai_goals:checkins:read`, `ai_goals:checkins:write`, `ai_goals:actions:read`, `ai_goals:actions:write`, `ai_goals:evidence:read`, `ai_goals:evidence:write`, `ai_goals:results:read`, `ai_goals:results:write`, `ai_goals:admin`
    9. **Internal Staff (HR)**: `internal_staff:read`, `internal_staff:write`, `internal_staff:milestones:read`, `internal_staff:milestones:write`, `internal_staff:import`, `internal_staff:audit:read`, `internal_staff:reviews:read`, `internal_staff:reviews:write`, `internal_staff:reviews:admin`
    10. **StudioGuard Lifecycle**: `score:creator`, `score:weather`, `score:crime`, `score:auto-fatality`, `uw:read`, `uw:evaluate`, `rate:mpl`, `rate:property`, `rate:gl`, `rate:cyber`, `rate:trimpay`, `quote:write`, `quote:read`, `bind:write`, `brdx:read`, `commission:read`, `commission:write`
    11. **Service Intelligence**: `service_enablement:read`, `service_enablement:report`, `gamification:read`, `achievements:read`
    12. **Policy Forms**: `policy_forms:read`, `policy_forms:draft_write`, `policy_forms:validate`, `policy_forms:preview`, `policy_forms:authoritative_pdf_read`, `policy_forms:authoritative_pdf_write`, `policy_issuance:read`, `policy_issuance:proof_read` — _All restricted to first-party principals (partnerType admin/internal/internal_system/ai/integration). External book-of-business keys receive 403 regardless of scope grants. Authoritative PDF upload/replace requires fresh four-step review and grants no approval._
    13. **Service Requests**: `plays:read`, `plays:start`, `play_runs:write`
  contact:
    name: Pulse Engineering
    email: dwight@stuckey.com
  license:
    name: Proprietary
servers:
  - url: https://covercore.services/api/v1
    description: Production
  - url: http://localhost:5000/api/v1
    description: Local dev
security:
  - BearerAuth: []
  - ApiKeyAuth: []
tags:
  - name: Thumbtack
    description: Owner-scoped provider search and staff lead pipeline. No booking or contact submissions.
  - name: Foundation
    description: Auth, key info, async jobs, health
  - name: Score
    description: Risk scoring (creator, weather, crime, auto-fatality)
  - name: Underwriting
    description: Underwriting evaluation (gates rating)
  - name: Rate
    description: Per-line rating (MPL, Property, GL, Cyber, TRiMPay)
  - name: Quote
    description: Quote assembly (combines rated lines into one quote)
  - name: External Quote Imports
    description: Internal carrier-pipeline synchronization; never bind or issuance authority
  - name: Bind
    description: Bind a quote into a policy + collect payment
  - name: BRDX
    description: Bordereau reporting (written / paid + Accelerant export)
  - name: Commission
    description: Public Pay Commission API (ledger, wallet, withdrawals)
  - name: Webhooks
    description: Webhook subscriptions and delivery logs
  - name: Service Requests
    description: Ticket-attached service plays and play runs
  - name: Chat Escalations
    description: Owner-bound staff handling of live chat escalations
  - name: Policyholder
    description: Governed agent account intake plus policyholder self-service
  - name: Credential Self-Service
    description: Browser-session routes for managing the signed-in user's own API keys

paths:
  /quotes/{applicationId}/head-start:
    post:
      operationId: startAgentHeadStart
      tags: [Quotes]
      summary: Start or read Head Start for an owned application
      description: New agent API access only. Requires quotes:write, an explicit per-agent staff grant, and verified direct agent ownership of a saved StudioGuard v18 application. Reuses existing research and provider reservations. Send no body; inputs come only from saved confirmed business facts. Repeat POST reads the existing run without starting duplicate paid work. Existing in-app Head Start is unchanged.
      parameters:
        - name: applicationId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Existing completed research in the standard applicant Head Start projection }
        '202': { description: Research started or still running in the standard applicant Head Start projection }
        '400': { description: Invalid identifier or caller-supplied inputs }
        '401': { description: Missing or invalid API key }
        '403': { description: Scope, agent identity or explicit grant required }
        '404': { description: Owned saved application not found or Head Start disabled }
        '422': { description: Confirm saved business details before research }
        '429': { description: Key rate limit exceeded }
        '503': { description: Head Start unavailable }
  /quotes/prefill-draft:
    post:
      operationId: createAgentPrefillDraft
      tags: [Quotes]
      summary: Save an unsent agent prefill draft
      description: Requires an agent-scoped key with quotes:write and a verified direct book identity. Saves only an unsent prefill record; no opportunity, email, rating or payment. Agent attribution is server-owned. Normal non-idempotent POST semantics apply.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [clientEmail, config]
              properties:
                clientEmail: { type: string, format: email, maxLength: 254 }
                clientName: { type: string, maxLength: 255 }
                clientPhone: { type: string, maxLength: 255 }
                clientHandle: { type: string, maxLength: 255 }
                agentNote: { type: string, maxLength: 2000 }
                config: { type: object, description: "Draft configuration only, with no agent or agency attribution keys. Maximum serialized size 32000 characters." }
      responses:
        '201':
          description: Unsent record saved; token and agent details are not returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string, enum: [unsent] }
                      expiresAt: { type: string, format: date-time }
        '400': { description: Invalid input or caller-supplied attribution }
        '401': { description: Missing or invalid key }
        '403': { description: Agent identity or quotes:write required }
        '429': { description: Existing key rate limit exceeded }
        '500': { description: Draft could not be saved }
  /my/billing:
    get:
      tags: [Policyholder]
      summary: List your billing records
      description: Only explicitly owned billing links are used; unclear, unlinked and conflicting rows are excluded. No internal notes or raw provider fields are returned. New scope must be granted explicitly.
      x-required-scopes: [my_billing:read]
      parameters:
        - $ref: "#/components/parameters/PolicyholderPage"
        - $ref: "#/components/parameters/PolicyholderLimit"
      responses:
        "200": { description: "data contains id, activityType, activityDate, dueDate, minimumDue, accountBalance, amountPaid and carrier; pagination contains page, limit, total and totalPages." }
        "400": { description: Invalid pagination or unsupported query parameters }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /my/tickets:
    get:
      tags: [Policyholder]
      summary: List your support tickets
      description: Explicit policyholder linkage is required. Returns only id, subject, status and createdAt, never staff notes, assignments or internal fields.
      x-required-scopes: [my_tickets:read]
      parameters:
        - $ref: "#/components/parameters/PolicyholderPage"
        - $ref: "#/components/parameters/PolicyholderLimit"
      responses:
        "200": { description: "data is the ticket list; pagination contains page, limit, total and totalPages." }
        "400": { description: Invalid pagination or unsupported query parameters }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Policyholder]
      summary: Open a support ticket for yourself
      description: Ownership is derived from the authenticated key, never the body. The new write scope must be granted explicitly. Creates an open support request without sending a message, assigning a staff member or changing a policy.
      x-required-scopes: [my_tickets:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [subject, description]
              properties:
                subject: { type: string, minLength: 1, maxLength: 200 }
                description: { type: string, minLength: 1, maxLength: 10000 }
      responses:
        "201": { description: "data contains the created ticket's id, subject, status and createdAt." }
        "400": { description: Invalid body or unsupported parameters }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /contacts/{id}/notes:
    post:
      operationId: createContactNote
      tags: [Contacts]
      x-required-scope: contacts:write
      summary: Add an entry to a contact's Notes feed
      description: >
        Requires contacts:write and existing restricted-key/book authorization.
        Resolves a Pulse contact id or FileMaker conId and links the note to conId,
        not contacts.notes. Date is server-owned (America/Chicago). API-key
        authorship stays in CoverCore, not FileMaker. The side panel continues
        to display description and date only. Every accepted POST creates a new
        note; retries are not idempotent. pending_push does not confirm delivery.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [description]
              properties:
                description:
                  type: string
                  minLength: 1
                  maxLength: 20000
                  description: Trimmed before length validation; whitespace-only is invalid.
                status:
                  type: string
                  enum: [active, follow-up, closed, sms_consent]
                  default: active
      responses:
        '201':
          description: Note persisted locally and queued for existing CGC push
          content:
            application/json:
              schema:
                type: object
                required: [id, contactId, conId, date, description, status, createdByApiKeyId, createdByApiKeyLabel, covercoreCreatedByUserId, syncStatus]
                properties:
                  id: { type: string }
                  contactId: { type: string, description: Resolved Pulse contacts.id }
                  conId: { type: string }
                  date: { type: string, format: date }
                  description: { type: string }
                  status: { type: string, enum: [active, follow-up, closed, sms_consent] }
                  createdByApiKeyId: { type: string }
                  createdByApiKeyLabel: { type: string }
                  covercoreCreatedByUserId: { type: [string, "null"] }
                  syncStatus: { type: string, const: pending_push }
        '400': { description: Invalid body or extra fields; existing error envelope }
        '401': { description: API key required or invalid; existing error envelope }
        '403': { description: Missing scope, restricted route or contact outside book; existing error envelope }
        '404': { description: Contact not found; existing error envelope }
        '422': { description: Contact has no FileMaker conId; existing error envelope }
  /staff/directory:
    get:
      operationId: staffDirectory
      tags: [Operations]
      x-required-scope: staff:read
      summary: Read-only internal staff directory
      description: >
        Lists internal staff (id, name, email, role, service-team flags,
        rotation order) and the mailboxes linked to them. Requires staff:read
        and a first-party internal key. Does not return passwords, tokens, or
        OAuth data. email_inbox:read does not grant this route.
      responses:
        '200': {description: Staff directory}
        '401': {description: API key required}
        '403': {description: Missing staff:read or key is not first-party}
  /scorecard/email-stats:
    get:
      operationId: scorecardEmailStats
      tags: [Operations]
      x-required-scope: email_stats:read
      summary: Per-person email stats by America/Chicago day
      description: >
        from and to are required YYYY-MM-DD dates, inclusive, capped at 92 days.
        Optional staffId filters to one user. groupBy must be day (the default).
        Counts come from stored email_classifications, outlook_messages, and
        ticket_messages, not the today-only mailbox counters. Requires
        email_stats:read and a first-party internal key.
      parameters:
        - name: from
          in: query
          required: true
          schema: {type: string, format: date}
        - name: to
          in: query
          required: true
          schema: {type: string, format: date}
        - name: staffId
          in: query
          required: false
          schema: {type: string}
        - name: groupBy
          in: query
          required: false
          schema: {type: string, enum: [day], default: day}
      responses:
        '200': {description: Daily stats}
        '400': {description: Invalid or oversized date range}
        '401': {description: API key required}
        '403': {description: Missing email_stats:read or key is not first-party}
  /thumbtack/categories:
    get:
      operationId: thumbtackCategories
      tags: [Thumbtack]
      x-required-scope: thumbtack:read
      summary: List installation categories
      responses: &thumbtackReadResponses
        '200': {description: Successful read}
        '401': {description: API key required}
        '403': {description: Owner role or scope denied}
        '422': {description: Invalid input; invalid_zip for unknown US ZIP}
        '429': {description: Rate limited; Retry-After is seconds, headers: {Retry-After: {schema: {type: integer}}}}
        '502': {description: Provider temporarily unavailable}
  /thumbtack/environment:
    get:
      operationId: thumbtackEnvironment
      tags: [Thumbtack]
      x-required-scope: thumbtack:read
      summary: Read environment and application name only
      responses:
        '200':
          description: No credentials or tokens
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [environment, app]
                properties:
                  environment: {type: string, enum: [staging, production]}
                  app: {type: string, enum: [Trimriskdev, trimriskprod]}
        '401': {description: API key required}
        '403': {description: Owner role or scope denied}
        '429': {description: Rate limited; Retry-After is seconds}
  /thumbtack/search:
    post:
      operationId: thumbtackSearch
      tags: [Thumbtack]
      x-required-scope: thumbtack:read
      x-rate-limit: 30/min agent, 60/min staff
      summary: Search installation providers within approximately 25 miles
      description: Read-only discovery; does not create leads. Category takes precedence, with sanitized trade query refinement via filtered search. Contact-like query text is redacted; brands are mapped to approved trade terms. Distances use city centroids, not verified street addresses. Unknown locations are excluded.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ThumbtackSearchInput'}
      responses: *thumbtackReadResponses
  /thumbtack/quick-search/{categoryId}:
    post:
      operationId: thumbtackQuickSearch
      tags: [Thumbtack]
      x-required-scope: thumbtack:read
      x-rate-limit: 30/min agent, 60/min staff
      summary: Search using a category from the categories endpoint
      description: Only the path category is used; body query/category fields are ignored.
      parameters:
        - {name: categoryId, in: path, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ThumbtackQuickSearchInput'}
      responses: *thumbtackReadResponses
  /thumbtack/search/{searchId}:
    get:
      operationId: thumbtackSearchResult
      tags: [Thumbtack]
      x-required-scope: thumbtack:read
      summary: Read an owned search result
      description: Other owners and unmapped legacy searches return 404. Admin/internal_staff have an audited read-only override. Ownership is assigned only for upstream business search IDs.
      parameters:
        - {name: searchId, in: path, required: true, schema: {type: string}}
      responses:
        <<: *thumbtackReadResponses
        '404': {description: Not found or not owner}
  /thumbtack/lead-settings:
    get:
      operationId: thumbtackLeadSettings
      tags: [Thumbtack]
      x-required-scope: thumbtack:read
      summary: Read lead-pipeline settings (staff/admin owners only)
      responses: *thumbtackReadResponses
    put:
      operationId: thumbtackUpdateLeadSettings
      tags: [Thumbtack]
      x-required-scope: thumbtack:write
      summary: Update lead-pipeline settings (admin owners only)
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ThumbtackSettingsInput'}
      responses: *thumbtackReadResponses
  /leads/thumbtack:
    post:
      operationId: thumbtackInboundLeads
      tags: [Thumbtack]
      x-required-scope: thumbtack:write
      x-rate-limit: 120/min per key, also subject to generic key limit
      summary: Post inbound leads owned by the key creator (staff/admin only)
      description: Supplied user_id is ignored. Dedup uses owner, normalized business name and ZIP; a different owner's match never merges.
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ThumbtackLeadInput'}
      responses: *thumbtackReadResponses
  /health:
    get:
      tags: [Foundation]
      summary: Health probe (no auth)
      security: []
      responses:
        "200":
          description: Service healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  version: { type: string, example: "1.0.0" }
                  timestamp: { type: string, format: date-time }
  /orders:
    get:
      tags: [Orders]
      summary: Read the internal ORD book
      x-required-scopes: [orders:read]
      description: |
        First-party keys only; agency, agent and policyholder keys are forbidden.
        Omitted effective-date bounds default to the last 7 America/Chicago
        calendar days inclusive of today, with appliedDateWindow.defaulted=true.
        Temporary guard: explicit or defaulted ranges must be at most 31 inclusive
        calendar days. Open-ended ranges are also rejected. Supply both
        effectiveDateFrom/effectiveDateTo for explicit ranges. Wider ranges return
        400 DATE_RANGE_TOO_WIDE before orders SQL, with appliedDateWindow retained.
        Line/agency filters do not suppress the default. Page size defaults to 100.
        The existing 15-second per-statement timeout and 504 remain unchanged.
      parameters: &ordersDateParameters
        - name: effectiveDateFrom
          in: query
          description: Inclusive Chicago effective day; requires effectiveDateTo, at most 31 days inclusive.
          schema: { type: string, format: date }
        - name: effectiveDateTo
          in: query
          description: Inclusive Chicago effective day; requires effectiveDateFrom, at most 31 days inclusive.
          schema: { type: string, format: date }
      responses: &ordersDateResponses
        "200":
          description: Orders result with appliedDateWindow (field, from, to, defaulted, timeZone).
        "400":
          description: Invalid parameters or DATE_RANGE_TOO_WIDE for over-31-day or open-ended ranges.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: object
                    properties:
                      code: { type: string }
                      message: { type: string }
                      retryable: { type: boolean }
                  appliedDateWindow:
                    type: object
                    description: Included on DATE_RANGE_TOO_WIDE responses.
                    properties:
                      field: { type: string, enum: [effective_date] }
                      from: { type: string, format: date, nullable: true }
                      to: { type: string, format: date, nullable: true }
                      defaulted: { type: boolean }
                      timeZone: { type: string, enum: [America/Chicago] }
              example:
                error:
                  code: DATE_RANGE_TOO_WIDE
                  message: Use effectiveDateFrom/effectiveDateTo with a range of 31 days or less.
                  retryable: false
                appliedDateWindow:
                  field: effective_date
                  from: "2026-03-01"
                  to: "2026-04-01"
                  defaulted: false
                  timeZone: America/Chicago
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "504":
          description: STATEMENT_TIMEOUT; the unchanged 15-second per-statement limit was reached.
  /orders/summary:
    get:
      tags: [Orders]
      summary: Summarize the internal ORD book
      x-required-scopes: [orders:read]
      description: |
        First-party keys only. The same temporary guard as /orders applies:
        at most 31 inclusive calendar days, rejecting open-ended ranges with
        400 DATE_RANGE_TOO_WIDE and appliedDateWindow before orders SQL.
        Omitted bounds default to the last 7 America/Chicago calendar days
        inclusive of today with appliedDateWindow.defaulted=true.
        Use effectiveDateFrom/effectiveDateTo with a range of 31 days or less.
        Grouping and exact totals are unchanged. The existing 15-second
        per-statement timeout and 504 remain unchanged.
      parameters: *ordersDateParameters
      responses: *ordersDateResponses
  /me:
    get:
      tags: [Foundation]
      summary: Inspect the authenticated API key
      responses:
        "200":
          description: API key metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ApiKeyInfo" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /jobs/{id}:
    get:
      tags: [Foundation]
      summary: Poll an async job
      description: |
        Long-running operations (creator-intelligence scoring, BRDX
        exports, etc.) return `202 Accepted` immediately with a job
        stub. Poll this endpoint until `status` is `succeeded` or
        `failed`. Optionally, register a webhook to be notified
        instead of polling.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Job state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Job" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─── Score ──────────────────────────────────────────────────────────
  /score/creator-intelligence:
    post:
      tags: [Score]
      summary: Score a creator/business risk profile (async)
      description: |
        Returns 202 + a job stub. Poll `/jobs/{id}` (or
        `GET /score/creator-intelligence/{jobId}`) for the result, or
        provide `webhookUrl` in the body and the result is delivered
        as `score.creator-intelligence.completed`.
        Required scope: `score:creator`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreatorScoreRequest" }
      responses:
        "202":
          description: Job queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Job" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /score/creator-intelligence/{jobId}:
    get:
      tags: [Score]
      summary: Read a creator-intelligence job
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Job state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Job" }
        "404": { $ref: "#/components/responses/NotFound" }
  /score/weather:
    post:
      tags: [Score]
      summary: Weather / CAT exposure score
      description: |
        Required scope `score:weather`. Synchronous. `state` and `zip`
        are required; `address`/`city`/`latitude`/`longitude` are
        optional and refine the result.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WeatherScoreRequest" }
      responses:
        "200":
          description: Score result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ScoreResultGeneric" }
        "400": { $ref: "#/components/responses/ValidationError" }
  /score/crime:
    post:
      tags: [Score]
      summary: Crime exposure score
      description: |
        Required scope `score:crime`. Synchronous. All four address
        fields (`address`, `city`, `state`, `zip`) are required.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CrimeScoreRequest" }
      responses:
        "200":
          description: Score result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ScoreResultGeneric" }
  /score/auto-fatality:
    post:
      tags: [Score]
      summary: Auto fatality exposure score
      description: |
        Required scope `score:auto-fatality`. Provide either a full
        address (`address`+`city`+`state`+`zip`) **or** coordinates
        (`lat`+`lng`+`radius_meters`) — not both.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              { $ref: "#/components/schemas/AutoFatalityScoreRequest" }
      responses:
        "200":
          description: Score result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/ScoreResultGeneric" }

  # ─── Underwriting ───────────────────────────────────────────────────
  /underwriting/evaluate:
    post:
      tags: [Underwriting]
      summary: Trigger an underwriting evaluation
      description: |
        Returns an evaluation row whose `id` MUST be passed to every
        `/rate/*` call. The evaluation embeds the chosen IRPM
        modifiers and the decision (`accept` / `refer` / `decline`).
        Sandbox keys may submit a `fixture_key` (one of the 15 golden
        fixtures, e.g. `clean_accept`); live keys must submit an
        `application` object.
        Required scope: `uw:evaluate`.

        Jurisdiction gate: StudioGuard is offered online in approved U.S.
        states only. An **Alaska (AK)**, **Hawaii (HI)**, or
        **outside-the-US** address is terminally unavailable — every
        requested line is `declined`, the decision is `decline`, and a
        blocking `jurisdiction_unavailable` concern is returned (with a
        stable `reason` of `unsupported_state_ak`, `unsupported_state_hi`,
        or `unsupported_country`). To express a non-US address in the
        `application` payload, set `address.country` to a non-US value
        (e.g. `"Canada"`); `address.state` may then be any 2-letter region.
        For a domestic application (country omitted or a US token),
        `address.state` MUST be a valid 2-letter U.S. state code or the
        request 400s.

        High-MPL referral: a requested `coverage_limits.mpl` at/above
        **$5,000,000** cannot be auto-accepted — the MPL line is
        `referred`, the decision is `refer`, and a `high_mpl_limit`
        concern is returned.

        Correlation: every response (incl. `400`) carries
        `X-CoverCore-Request-Id` and `X-CoverCore-Trace-Id`. Send
        `x-external-builder-run-id` and/or `x-client-request-id` on the
        request to tie your run together in the API request ledger.

        The five external-builder quickstart scenarios map to these
        fixtures: `studioguard_creator_mo_approved` (accept),
        `studioguard_creator_ak_unavailable`,
        `studioguard_creator_hi_unavailable`,
        `studioguard_creator_outside_us_unavailable` (all decline), and
        `studioguard_creator_high_mpl_referral` (refer).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UnderwritingRequest" }
      responses:
        "201":
          description: Evaluation created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/UnderwritingEvaluation" }
        "400": { $ref: "#/components/responses/ValidationError" }
  /underwriting/evaluations/{id}:
    get:
      tags: [Underwriting]
      summary: Read an underwriting evaluation
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Evaluation
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/UnderwritingEvaluation" }
        "404": { $ref: "#/components/responses/NotFound" }
  /underwriting/evaluations/{id}/additional-info:
    post:
      tags: [Underwriting]
      summary: Submit follow-up info on a referred evaluation
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [answers]
              properties:
                answers:
                  type: object
                  additionalProperties:
                    oneOf:
                      - { type: string }
                      - { type: number }
                      - { type: boolean }
                notes: { type: string, maxLength: 2000 }
      responses:
        "200":
          description: Updated evaluation
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/UnderwritingEvaluation" }

  # ─── Rate ───────────────────────────────────────────────────────────
  # Each /rate/* endpoint requires a valid evaluation_id from
  # /underwriting/evaluate. If absent or stale, the gate returns
  # 409 EVALUATION_REQUIRED.
  /rate/mpl:
    post:
      tags: [Rate]
      summary: Rate Media Professional Liability
      description: |
        Required scope: `rate:mpl`. `evaluation_id` MUST point at a
        valid, accepted underwriting evaluation; otherwise 409
        `EVALUATION_REQUIRED`. Provide either `revenue_allocations`
        OR `services` + `business_description` (used for AI
        classification).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MplRateRequest" }
      responses:
        "201":
          description: Rated line
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLineResponse" }
        "409": { $ref: "#/components/responses/EvaluationRequired" }
  /rate/property:
    post:
      tags: [Rate]
      summary: Rate Property
      description: Required scope `rate:property`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              { $ref: "#/components/schemas/PropertyRateRequest" }
      responses:
        "201":
          description: Rated line
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLineResponse" }
        "409": { $ref: "#/components/responses/EvaluationRequired" }
  /rate/gl:
    post:
      tags: [Rate]
      summary: Rate General Liability
      description: Required scope `rate:gl`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/GlRateRequest" }
      responses:
        "201":
          description: Rated line
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLineResponse" }
        "409": { $ref: "#/components/responses/EvaluationRequired" }
  /rate/cyber:
    post:
      tags: [Rate]
      summary: Rate Cyber
      description: Required scope `rate:cyber`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CyberRateRequest" }
      responses:
        "201":
          description: Rated line
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLineResponse" }
        "409": { $ref: "#/components/responses/EvaluationRequired" }
  /rate/trimpay:
    post:
      tags: [Rate]
      summary: Rate TRiMPay credit / debit
      description: Required scope `rate:trimpay`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              { $ref: "#/components/schemas/TrimpayRateRequest" }
      responses:
        "201":
          description: Rated line
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RateLineResponse" }
        "409": { $ref: "#/components/responses/EvaluationRequired" }

  # ─── External quote pipeline imports ───────────────────────────────
  /quotes:
    get:
      tags: [External Quote Imports]
      summary: List authorized quote-pipeline records
      security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
      x-required-scopes: [quotes:read]
      description: |
        Caller-visible change: omitted submittedAt/submittedAtFrom/submittedAtTo
        (including snake_case aliases), effectiveDateFrom/effectiveDateTo and
        quoteDateFrom/quoteDateTo now
        default to the last 90 America/Chicago calendar days inclusive of today
        on submitted_at, not all-time. Submitted-at and effective-date parameters
        preserve their existing open-ended behavior. Quote-date parameters
        (including quote_date_from/quote_date_to aliases) suppress that default,
        but require BOTH bounds and at most 31 inclusive Chicago calendar days.
        Wider or open-ended quote-date ranges return 400 DATE_RANGE_TOO_WIDE,
        retryable=false, with appliedDateWindow.field=quote_date before book
        resolution or quotes SQL, including sandbox requests.
        Other filters do not suppress the default. Fetch longer quote-date
        history in separate non-overlapping windows of 31 days or less.
        Page size remains 20 by default, capped at 100. Additive appliedDateWindow
        returns from, to, field, defaulted, timeZone and any explicit effective-date
        intersection, including count-only and empty-book responses.
        Quotes reads have a 15-second request deadline and a transaction-local
        statement_timeout covering the shared count/page query. Deadline or
        database cancellation returns 504 STATEMENT_TIMEOUT, retryable=true;
        no partial rows or totals are returned. The underlying SQL is cancelled
        by PostgreSQL even when the HTTP deadline expires first.

        Required scope: `quotes:read`. No extra scope. Returns legacy quote records plus
        external carrier imports visible to the authenticated key. Agency and
        agent keys remain restricted to proven book-of-business ownership;
        sandbox and live imports are isolated. Approval-like, Rated, and
        external Issued statuses never mean CoverCore bind or issuance.

        `productLob` is an exact, case-insensitive match on `quotes.product_lob`
        (comma-separated list allowed). It does not change `coverageType`, which
        stays a case-insensitive substring match.

        `quoteDateFrom` and `quoteDateTo` are inclusive America/Chicago calendar
        days (`YYYY-MM-DD`) on `quotes.date` — the quote's own date. FileMaker
        sync stores the FileMaker `date` field there; external import stores
        `quote_date` there. This is not `submitted_at`, `effective_date`, or
        `create_timestamp`. Date-only text is that Chicago day. Zone-less
        timestamps are Chicago wall time. Offset-bearing timestamps use their
        instant. The window is half-open and DST-safe (23 hours on spring-forward,
        25 hours on fall-back). The predicate is applied in SQL.

        `sort` is `quoteDate`, `submittedAt`, `effectiveDate`, or `premium`,
        with optional `:asc` or `:desc`. Omitted sort is `quoteDate:desc`
        (newest quote date first). `id` ascending is always the tiebreak.
        Null and unparseable values sort last. `pagination.total` is the
        filtered count. `countOnly=true` returns that count and an empty `data`
        array without loading the page.
      parameters:
        - name: carrier_id
          in: query
          schema: { type: string }
        - name: external_quote_id
          in: query
          schema: { type: string }
        - name: source
          in: query
          schema: { type: string }
        - name: status
          in: query
          description: Normalized external business status (or legacy sync status).
          schema: { type: string }
        - name: coverageType
          in: query
          description: Case-insensitive substring match on coverage_type. Unchanged.
          schema: { type: string }
        - name: productLob
          in: query
          description: Exact case-insensitive product_lob match. Comma-separated list allowed.
          schema: { type: string }
        - name: quoteDateFrom
          in: query
          description: Inclusive Chicago day on quotes.date; requires quoteDateTo and at most 31 inclusive days. Alias quote_date_from.
          schema: { type: string, format: date }
        - name: submittedAt
          in: query
          description: Single inclusive Chicago calendar day on submitted_at; suppresses the default window. Alias submitted_at.
          schema: { type: string, format: date }
        - name: submittedAtFrom
          in: query
          description: Inclusive Chicago start day on submitted_at; omitted end stays open. Alias submitted_at_from.
          schema: { type: string, format: date }
        - name: submittedAtTo
          in: query
          description: Inclusive Chicago end day on submitted_at; omitted start stays open. Alias submitted_at_to.
          schema: { type: string, format: date }
        - name: effectiveDateFrom
          in: query
          description: Inclusive existing effective_date lower-bound comparison; suppresses the default submitted_at window.
          schema: { type: string, format: date }
        - name: effectiveDateTo
          in: query
          description: Inclusive existing effective_date upper-bound comparison; suppresses the default submitted_at window.
          schema: { type: string, format: date }
        - name: quoteDateTo
          in: query
          description: Inclusive Chicago day on quotes.date; requires quoteDateFrom and at most 31 inclusive days. Alias quote_date_to.
          schema: { type: string, format: date }
        - name: sort
          in: query
          description: quoteDate, submittedAt, effectiveDate, or premium, with optional :asc or :desc. Default quoteDate:desc, then id ascending.
          schema: { type: string, example: "quoteDate:desc" }
        - name: countOnly
          in: query
          description: When true, pagination.total is the filtered count and data is empty.
          schema: { type: boolean }
        - name: contact_id
          in: query
          schema: { type: string }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200":
          description: Authorized quote-pipeline records
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalQuoteListResponse" }
        "400":
          description: Invalid filters, or DATE_RANGE_TOO_WIDE for a quote-date range over 31 days or missing a bound.
          content:
            application/json:
              example:
                error:
                  code: DATE_RANGE_TOO_WIDE
                  message: Use quoteDateFrom/quoteDateTo with a range of 31 days or less.
                  retryable: false
                appliedDateWindow:
                  field: quote_date
                  from: "2026-11-01"
                  to: null
                  defaulted: false
                  timeZone: America/Chicago
        "504":
          description: STATEMENT_TIMEOUT; the 15-second quotes-read deadline or database statement timeout expired.
          content:
            application/json:
              example:
                error:
                  code: STATEMENT_TIMEOUT
                  message: Quotes read exceeded the 15-second deadline. Narrow the filters and retry.
                  retryable: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /quotes/import:
    post:
      tags: [External Quote Imports]
      summary: Import or update outside-carrier pipeline rows
      security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
      x-required-scopes: [quotes:write]
      description: |
        Required scope: `quotes:write`. Accepts one record or a bounded batch
        of up to 200 rows (large enough for the initial Hartford ICON sync).
        Ownership may be supplied as canonical `ownership`, resolved using the
        existing `ownershipFrom` portal shape, or inferred from a valid
        agent-bound key. `contact_id` is optional; when present alongside
        `ownership.contactId`, the canonical identities must agree. An optional
        `policyholder_id` must identify an existing in-book policyholder.

        This is pipeline synchronization only. It does not call underwriting
        evaluation or rating and never invents premium, coverage, limits,
        forms, or dates. `transaction_type` remains optional for existing import
        clients: Hartford defaults an omitted value to `new_business`, while
        Travelers (`source = travelers_policy_activity`) requires an explicit
        discriminator at runtime. Omitted insurance fields remain absent/null. Imports are
        always visible only to staff and the assigned agent, never to a
        policyholder. The natural idempotency key is
        `(environment, carrier_id, external_quote_id)`; database uniqueness is
        authoritative under retries and concurrency. A retry cannot transfer
        ownership or clear a proven policyholder link. `Idempotency-Key` adds
        request replay protection.

        Ownership namespaces are explicit: `ownership.agencyId` and
        `fallback_agency_id` accept the exact `agencies.id` or its persisted
        `agencies.ptnUuid`, while the response returns canonical `agencies.id`.
        `contact_id`, `ownership.contactId`, and `fallback_contact_id` are
        `contacts.id`, not carrier producer codes. A producer code is evidence
        only and cannot create or infer an owner.

        `submitted_at` is optional date evidence. When omitted, the server
        uses parsed portal `last_activity`, then the first import timestamp.
        Offset-bearing ISO-8601 values retain their instant; date-only and
        offset-less values are `America/Chicago`. Collection day filters use
        the DST-safe half-open range `[start_of_day, start_of_next_day)` in
        that timezone (23 hours on spring-forward days and 25 hours on
        fall-back days). Replays without new evidence preserve their bucket;
        changed portal activity can move the row. Carrier status is pipeline
        evidence only: `Approved`, `Rated`, and `Issued` never grant
        evaluation, bind, or issuance authority.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExternalQuoteImportInput" }
            examples:
              hartfordIcon:
                summary: One Hartford ICON pipeline row with omitted insurance terms
                value:
                  source: hartford_icon
                  carrier_id: 72dbfa05-5b49-4790-82de-125be1f3b5d0
                  external_quote_id: QA-HARTFORD-ICON-0001
                  status: Rated
                  ownership:
                    agencyId: qa-agency-id
                    contactId: qa-contact-id
                  named_insured: QA Studio LLC
                  product_lob: Business Owner Policy
                  premium: null
                  effective_date: null
                  quote_date: null
                  submitted_at: "2026-09-11T14:30:00Z"
                  metadata:
                    portal: ICON
              travelersPolicyActivity:
                summary: One Travelers New Business row; use only isolated QA ownership and policyholder fixtures
                value:
                  source: travelers_policy_activity
                  carrier_id: 0fb728c5-ca68-451b-b33b-3d3d8871678d
                  external_quote_id: QA-TRAVELERS-NB-0001
                  status: New Business
                  transaction_type: new_business
                  ownership:
                    agencyId: qa-agency-id
                    contactId: qa-contact-id
                  policyholder_id: qa-existing-policyholder-id
                  named_insured: QA Plaza LLC
                  last_activity: "2026-09-11 09:30"
              mixedCarrierBulk:
                summary: Bounded normalized JSON batch; every item has independent field paths on validation failure
                value:
                  quotes:
                    - source: hartford_icon
                      carrier_id: 72dbfa05-5b49-4790-82de-125be1f3b5d0
                      external_quote_id: QA-HARTFORD-ICON-0002
                      status: Declined
                      ownership:
                        agencyId: qa-agency-id
                        contactId: qa-contact-id
                    - source: travelers_policy_activity
                      carrier_id: 0fb728c5-ca68-451b-b33b-3d3d8871678d
                      external_quote_id: QA-TRAVELERS-NB-0002
                      status: New Business
                      transaction_type: new_business
                      ownership:
                        agencyId: qa-agency-id
                        contactId: qa-contact-id
      responses:
        "200":
          description: Existing record updated or bulk results returned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalQuoteImportResponse" }
        "201":
          description: Single record created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalQuoteImportCreatedResponse" }
        "400": { $ref: "#/components/responses/ExternalQuoteImportValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Contact or policyholder is outside the authenticated book of business
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalQuoteImportErrorResponse" }
        "404":
          description: Carrier, contact, or policyholder was not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalQuoteImportErrorResponse" }
        "409":
          description: Ownership or idempotency conflict
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalQuoteImportErrorResponse" }
        "500":
          description: Import failed after validation; no lifecycle action is performed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalQuoteImportErrorResponse" }
        "503":
          description: Idempotency protection is temporarily unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalQuoteImportErrorResponse" }
  /quotes/submissions:
    get:
      tags: [External Quote Imports]
      summary: List authorized external carrier submissions
      security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
      x-required-scopes: [quotes:read]
      description: |
        Static collection route evaluated before `/quotes/{id}`. It exposes
        authorized external-carrier pipeline rows, not a new native StudioGuard
        submission model.

        This collection is a reporting projection, not lifecycle authority.
        For staff, the Home total is the effective, post-overlay count of
        distinct rows in the three workable StudioGuard lanes:
        `actionable_submission + studioguard_lead + needs_review`. An eligible
        imported row may join one of those non-excluded lanes with explicit
        carrier-import attribution and is counted at most once. The session
        dashboard exposes `metrics.submissions.external = { count, preview }`
        as a labeled subset of that total, not an additional counter; it must
        never be added to `metrics.submissions.total`. The effective channel
        projection uses a reporting-only `carrierImport` bucket. Excluded
        activity remains inspectable but is not counted.

        External rows are eligible when they are in the caller's environment
        and authorized book, visible to staff or the assigned agent, and their
        `submittedAt` is in the requested America/Chicago calendar day.
        Declined and expired rows are included by default. Travelers defaults to
        `new_business`; pass a transaction filter to review retained renewal or
        endorsement evidence. Policyholders cannot read unfinished external
        imports, including a carrier row whose raw status says `Issued`.
      x-kpi-definition:
        name: Home Submissions
        timezone: America/Chicago
        nativeProjection:
          workableLanes:
            - actionable_submission
            - studioguard_lead
            - needs_review
          excludedLane: excluded (visible for audit, not counted)
          channels: [portal, email, direct, aiIntake, carrierImport]
          channelRule: "Each workable row is assigned once; channel sum equals workable total."
          timezone: America/Chicago
        externalProjection:
          field: metrics.submissions.external
          shape: "{ count, preview }"
          subsetOfHomeTotal: true
          additiveToHomeTotal: false
          attribution: "Eligible imported rows are assigned once to the workable carrierImport bucket; this field labels that subset and is not additive."
        collection: "This operation returns external rows and pagination; the staff dashboard uses the same effective feed and labels carrier-import attribution separately."
      parameters:
        - name: externalQuoteId
          in: query
          description: Exact carrier portal ID.
          schema: { type: string, minLength: 1, maxLength: 255 }
        - name: carrier_id
          in: query
          schema: { type: string, minLength: 1 }
        - name: submitted_at
          in: query
          description: Chicago calendar day in `YYYY-MM-DD`, not a UTC day.
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
        - name: agencyId
          in: query
          description: Still constrained to the requesting key's authorized book.
          schema: { type: string, minLength: 1 }
        - name: contact_id
          in: query
          description: Exact canonical `contacts.id`, constrained to the caller's book.
          schema: { type: string, minLength: 1 }
        - name: status
          in: query
          description: Comma-separated normalized statuses; omitted includes Declined and Expired.
          schema:
            type: string
            example: "submitted,declined"
        - name: transaction_type
          in: query
          description: Comma-separated transaction types; omitted excludes Travelers renewal and endorsement evidence.
          style: form
          explode: false
          schema:
            type: array
            items: { $ref: "#/components/schemas/ExternalQuoteTransactionType" }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200":
          description: |
            Authorized eligible external carrier rows. `pagination.total` is
            the external-carrier count for this collection only; it is not the
            staff Home `metrics.submissions.total`. The dashboard summary uses
            a separate `{ count, preview }` external projection.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExternalQuoteListResponse" }
        "400": { $ref: "#/components/responses/ExternalQuoteImportValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /quotes/{id}:
    get:
      tags: [External Quote Imports]
      summary: Read an authorized quote-pipeline record
      security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
      x-required-scopes: [quotes:read]
      description: Required scope `quotes:read`; book and environment isolation match the list route.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Quote-pipeline record
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ExternalQuoteRecord" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─── Evaluated and rated StudioGuard quote assembly ─────────────────
  /quote:
    post:
      tags: [Quote]
      summary: Assemble a quote from rated lines
      description: |
        Combines previously-rated `line_ids` into a single quote with
        surplus-lines tax + monthly schedule + hosted URL.
        Required scope: `quote:write`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QuoteAssembleRequest" }
      responses:
        "201":
          description: Quote created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Quote" }
  /quote/{id}:
    get:
      tags: [Quote]
      summary: Read an assembled quote
      description: Required scope `quote:read`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Quote" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─── Bind ───────────────────────────────────────────────────────────
  /bind:
    post:
      tags: [Bind]
      summary: Bind a quote into a policy
      description: |
        Required scope: `bind:write`. Returns `policy_id` and a
        snapshot of the commission split. Emits `policy.bound`.
        Returns `404 QUOTE_NOT_FOUND` if `quote_id` is unknown or
        does not belong to the calling API key.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BindRequest" }
      responses:
        "201":
          description: Policy bound
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/BoundPolicy" }
        "404": { $ref: "#/components/responses/NotFound" }
  /bind/{policy_id}/payment-intent:
    post:
      tags: [Bind]
      summary: Create a Stripe PaymentIntent for the bound policy
      description: |
        Required scope: `bind:write`. Returns a Stripe `client_secret`
        the partner uses to collect first payment. Stripe webhooks
        translate to `policy.payment.succeeded` /
        `policy.payment.failed` / `policy.payment.cancelled`.
      parameters:
        - name: policy_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                installment_number: { type: integer, minimum: 1, maximum: 12 }
      responses:
        "201":
          description: PaymentIntent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/PaymentIntent" }

  # ─── BRDX ───────────────────────────────────────────────────────────
  /brdx/written:
    get:
      tags: [BRDX]
      summary: Bordereau — policies written
      description: Required scope `brdx:read`. Partner-tenanted.
      parameters:
        - name: period
          in: query
          schema: { type: string, example: "2026-04" }
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        "200":
          description: Written bordereau
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BrdxWrittenRow" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
  /brdx/paid:
    get:
      tags: [BRDX]
      summary: Bordereau — payments collected
      description: Required scope `brdx:read`. Partner-tenanted.
      parameters:
        - name: period
          in: query
          schema: { type: string }
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        "200":
          description: Paid bordereau
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BrdxPaidRow" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
  /brdx/{report_id}/export:
    get:
      tags: [BRDX]
      summary: Download a partner-scoped Accelerant `.xlsx` export
      parameters:
        - name: report_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: xlsx file
          content:
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema: { type: string, format: binary }

  # ─── Commission ─────────────────────────────────────────────────────
  /commissions/ledger:
    get:
      tags: [Commission]
      summary: Filterable commission ledger
      description: |
        Required scope: `commission:read`. External (non-admin) keys
        MUST scope by `agent_id`; admin / internal keys may omit it. For an
        agent-scoped key, `agent_id` must be one of the direct FileMaker agent
        identifiers resolved from the key's immutable user/contact linkage.
      parameters:
        - name: agent_id
          in: query
          schema: { type: string }
        - name: status
          in: query
          description: Filter by ledger status, or pass `all` to disable filtering.
          schema:
            type: string
            enum:
              [
                pending,
                verified,
                payable,
                paid,
                held,
                reversed,
                clawed_back,
                all,
              ]
        - name: carrier
          in: query
          schema: { type: string }
        - name: policy_number
          in: query
          schema: { type: string }
        - name: period
          in: query
          schema: { type: string, example: "2026-04" }
        - name: effective_date_from
          in: query
          schema: { type: string, format: date }
        - name: effective_date_to
          in: query
          schema: { type: string, format: date }
        - name: page
          in: query
          schema: { type: integer, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        "200":
          description: Ledger page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CommissionLedgerRow" }
                  pagination: { $ref: "#/components/schemas/Pagination" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /commissions/wallet/{agent_id}:
    get:
      tags: [Commission]
      summary: Agent wallet (pending / payable / paid + compliance)
      description: |
        Required scope: `commission:read`. Returns balances, the next
        aging date, the Lexi compliance gate snapshot, and licensed
        states. Agent-scoped keys may only read a directly resolved
        FileMaker agent identity; an agency or user id is not a substitute.
      parameters:
        - name: agent_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Wallet snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/CommissionWallet" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /commissions/withdrawals:
    post:
      tags: [Commission]
      summary: Request a commission withdrawal
      description: |
        Required scope: `commission:write`. Idempotent. `amount` may
        be either a positive number (partial) or the sentinel string
        `"max payable"` (sweep entire payable balance). Atomic; uses
        FIFO whole-row selection.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WithdrawalRequest" }
      responses:
        "201":
          description: Withdrawal accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Withdrawal" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  # ─── Webhooks ───────────────────────────────────────────────────────
  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      description: Required scope `webhooks:manage`.
      responses:
        "200":
          description: Subscriptions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookSubscription" }
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      description: |
        Required scope `webhooks:manage`. `chat_escalation.created` is the
        supported wake-up event for chat bots. It additionally requires a live,
        owner-bound, first-party key with `chat_escalations:read`; sandbox keys
        do not receive live chat events.

        Optional `headers` are static delivery headers. `Authorization` is
        allowed, but unsafe names are rejected: transport/hop-by-hop headers,
        `Content-Type`, `X-Pulse-*`, proxy/`Sec-*`/`X-Forwarded-*` headers, and
        cookie/origin/referrer-style headers. Header values are returned in
        this create response only and are omitted from GET/list responses.

        The returned webhook HMAC `secret` is separate from the `pk_live_*` or
        `pk_test_*` API credential. It is disclosed only in this creation
        response (or a later explicit rotation response), never by reads.

        `url` must be a public HTTPS endpoint. Localhost, private, link-local,
        reserved, and mixed public/private DNS results are rejected. Delivery
        revalidates and pins the resolved public address, and redirects are not
        followed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  description: Public HTTPS endpoint; internal/reserved targets and redirects are not allowed.
                events:
                  type: array
                  items:
                    type: string
                    description: Supported event name, including `chat_escalation.created`.
                    example: chat_escalation.created
                headers:
                  $ref: "#/components/schemas/WebhookDeliveryHeaders"
      responses:
        "201":
          description: Subscription created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookSubscription" }
  /webhooks/{id}:
    get:
      tags: [Webhooks]
      summary: Read a webhook subscription
      description: Required scope `webhooks:manage`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Subscription
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookSubscription" }
    patch:
      tags: [Webhooks]
      summary: Update a webhook subscription
      description: |
        Toggle a subscription on/off, update its event list, and/or replace its
        static delivery headers. Supplying `headers` replaces the complete
        header map; `{}` clears it. Header values are returned only when this
        request includes `headers`, and remain omitted from GET/list responses.
        Reactivating an auto-paused subscription (setting
        `isActive: true` when it was previously `false`) atomically
        resets `failureCount` to `0` so the next single failed
        delivery does not immediately re-trip the 10-failure
        auto-pause threshold. The signing `secret` is preserved, so
        partners can bring a paused subscription back online without
        re-recording credentials. At least one of `isActive`, `events`, or
        `headers` must be provided. Required scope: `webhooks:manage`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                isActive: { type: boolean }
                events:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    description: Supported event name, including `chat_escalation.created`.
                    example: chat_escalation.created
                headers:
                  $ref: "#/components/schemas/WebhookDeliveryHeaders"
      responses:
        "200":
          description: Subscription updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookSubscription" }
        "400":
          description: Validation error (empty body, wrong types, empty `events`, or a rejected header name/value).
        "404":
          description: Subscription not found for this API key.
  /webhooks/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate the signing secret for a subscription
      description: |
        Generates a fresh 32-byte hex HMAC signing secret, persists it
        on the subscription, and returns the new plaintext value in
        the response. The subscription's `id`, `url`, `events`,
        `isActive` flag, and delivery history are preserved — only
        the `secret` changes — so partners can rotate after a
        suspected leak without recreating the subscription.

        The previous secret stops being valid the instant this
        response is returned. Any in-flight delivery signed with the
        old secret will continue to verify on the partner side using
        the old secret it was signed with, but every delivery
        attempted **after** this call is signed with the new secret.
        The new secret is only ever returned by this endpoint and by
        `POST /webhooks` on creation; subsequent reads (including
        `GET /webhooks` and `GET /webhooks/{id}`) never include it.

        Required scope: `webhooks:manage`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Secret rotated. The response body contains the new plaintext secret in the `data.secret` field — store it now, it will never be returned again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/WebhookSubscription" }
        "404":
          description: Subscription not found for this API key.
  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      summary: Send a test event to a subscription
      description: |
        Performs a synchronous `test.ping` POST to the subscription
        URL using the subscription's HMAC secret and returns the
        delivery result. The body is `{event:"test.ping", timestamp,
        data:{message}}`. Required scope: `webhooks:manage`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Test delivery completed (synchronous).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      eventId: { type: string }
                      status:
                        type: string
                        enum: [delivered, failed]
                      responseStatus: { type: integer, nullable: true }
                      duration: { type: integer, description: Milliseconds end-to-end. }
                      error: { type: string, nullable: true }
  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List recent deliveries for a subscription
      description: |
        Paginated by `page` and `limit` (default 1/20, max limit 100).
        Required scope: `webhooks:manage`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200":
          description: Recent deliveries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        eventType: { type: string }
                        eventId: { type: string }
                        payload: { type: object, description: The exact JSON body Pulse POSTed. }
                        responseStatus: { type: integer, nullable: true }
                        responseBody: { type: string, nullable: true, description: First 1000 bytes of the partner response. }
                        attemptNumber: { type: integer }
                        status:
                          type: string
                          enum: [pending, processing, delivered, failed, exhausted]
                        duration: { type: integer, nullable: true, description: Milliseconds end-to-end. }
                        error: { type: string, nullable: true }
                        createdAt: { type: string, format: date-time }
                  pagination:
                    type: object
                    properties:
                      page: { type: integer }
                      limit: { type: integer }
                      total: { type: integer }
                      totalPages: { type: integer }

  /service-enablement/daily-report:
    get:
      tags: [Service Intelligence]
      summary: Service Enablement daily report (compact, expandable)
      description: |
        Stable compact daily/trailing-7 Service Enablement report (score,
        actionableTickets, accessEnabledTicketCount, recognition counts,
        examples per period). Expanded sections are opt-in via
        `include=classifications,gamification,achievements,recognition` or the
        equivalent `includeClassifications=true` style flags; include flags
        additionally require `service_enablement:report`. Score is null (never
        a fabricated 0%) below the minimum denominator; manual recognition can
        never move the score. Required scope: `service_enablement:read`
        (legacy `service_requests:read` keys still accepted). First-party
        internal keys only (staff recognition data).
      parameters:
        - name: date
          in: query
          schema: { type: string, format: date }
        - name: include
          in: query
          schema: { type: string, example: "classifications,gamification,achievements,recognition" }
      responses:
        "200": { description: Compact report, with expanded sections when requested. }
        "403": { description: Missing scope or not a first-party key. }
  /service-enablement/report:
    get:
      tags: [Service Intelligence]
      summary: Full expanded Service Intelligence report
      description: |
        All expanded sections (classificationBreakdown, staffBreakdown,
        gamification, achievements, denominator) for yesterday and the
        trailing 7 days. `period=daily|trailing7` returns a single block.
        Required scope: `service_enablement:report`. First-party keys only.
      parameters:
        - name: date
          in: query
          schema: { type: string, format: date }
        - name: period
          in: query
          schema: { type: string, enum: [daily, trailing7] }
      responses:
        "200": { description: Expanded report. }
        "403": { description: Missing scope or not a first-party key. }
  /service-enablement/classification-summary:
    get:
      tags: [Service Intelligence]
      summary: Classification taxonomy summary
      description: |
        Actionable-ticket counts across the 5 classification keys
        (agent_access_enabled_work, stuckey_required_service,
        training_or_access_enablement, new_agent_signup_or_access_grant,
        unknown) plus the denominator block, for both periods. Required scope:
        `service_enablement:read`. First-party keys only.
      parameters:
        - name: date
          in: query
          schema: { type: string, format: date }
      responses:
        "200": { description: Classification summary. }
        "403": { description: Missing scope or not a first-party key. }
  /service-enablement/staff-summary:
    get:
      tags: [Service Intelligence]
      summary: Per-staff enablement contribution
      description: |
        staffBreakdown per period (actionable tickets, per-staff
        classification breakdown, recognition events, XP, badges, missions).
        Required scope: `service_enablement:report`. First-party keys only.
      parameters:
        - name: date
          in: query
          schema: { type: string, format: date }
      responses:
        "200": { description: Staff summary. }
        "403": { description: Missing scope or not a first-party key. }
  /gamification/daily-report:
    get:
      tags: [Service Intelligence]
      summary: Gamification daily report
      description: |
        Service-enablement XP totals and event counts, top recognition,
        missions completed and badges earned per period. Required scope:
        `gamification:read`. First-party keys only.
      parameters:
        - name: date
          in: query
          schema: { type: string, format: date }
      responses:
        "200": { description: Gamification report. }
        "403": { description: Missing scope or not a first-party key. }
  /gamification/achievements:
    get:
      tags: [Service Intelligence]
      summary: Achievement catalog
      description: |
        Service-enablement badge and mission definitions (no per-user data).
        Required scope: `gamification:read`. First-party keys only.
      responses:
        "200": { description: Badge + mission catalog. }
        "403": { description: Missing scope or not a first-party key. }
  /achievements/daily-report:
    get:
      tags: [Service Intelligence]
      summary: Achievements daily report
      description: |
        Badge/mission definitions plus awards earned in the window for both
        periods. Required scope: `achievements:read`. First-party keys only.
      parameters:
        - name: date
          in: query
          schema: { type: string, format: date }
      responses:
        "200": { description: Achievements report. }
        "403": { description: Missing scope or not a first-party key. }

  # ─── Chat Escalations ───────────────────────────────────────────────
  /chat-escalations:
    get:
      tags: [Chat Escalations]
      summary: List chat escalations
      description: |
        Required scope: `chat_escalations:read`. Live, owner-bound first-party
        staff keys only. Subscribe to `chat_escalation.created` with
        `POST /webhooks`, verify the HMAC over the raw delivery body, then use
        its `data.escalationId` to claim, fetch messages, respond, and resolve.
        The signed JSON body is `{ event, id, createdAt, data }`; escalation
        fields are nested inside `data`.
        `status` accepts comma-separated values. Listing `status=pending`
        remains a catch-up path after downtime, not the primary wake-up path.
      x-required-scopes: [chat_escalations:read]
      parameters:
        - name: status
          in: query
          description: Comma-separated escalation statuses.
          schema:
            type: string
            example: pending,assigned
        - name: priority
          in: query
          schema: { type: string, enum: [low, normal, high, urgent] }
        - name: assignment
          in: query
          schema: { type: string, enum: [all, me, assigned, unassigned] }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1 }
      responses:
        "200": { description: Paginated escalations visible to the staff owner. }
        "403": { $ref: "#/components/responses/ChatEscalationForbidden" }
  /chat-escalations/{id}:
    get:
      tags: [Chat Escalations]
      summary: Read a chat escalation
      description: |
        Required scope: `chat_escalations:read`. Live, owner-bound first-party
        staff keys only.
      x-required-scopes: [chat_escalations:read]
      parameters:
        - $ref: "#/components/parameters/ChatEscalationId"
      responses:
        "200": { description: Chat escalation detail. }
        "403": { $ref: "#/components/responses/ChatEscalationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /chat-escalations/{id}/messages:
    get:
      tags: [Chat Escalations]
      summary: Poll chat escalation messages
      description: |
        Required scope: `chat_escalations:read`. For an initial lower bound,
        `after` accepts an exclusive ISO-8601 timestamp. For collision-safe
        pagination, pass the prior response's opaque `pagination.nextAfter`
        value unchanged. Live, owner-bound first-party staff keys only.
      x-required-scopes: [chat_escalations:read]
      parameters:
        - $ref: "#/components/parameters/ChatEscalationId"
        - name: after
          in: query
          description: An initial exclusive ISO-8601 timestamp or an opaque nextAfter cursor returned by this endpoint.
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1 }
      responses:
        "200": { description: Ordered escalation messages. }
        "403": { $ref: "#/components/responses/ChatEscalationForbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Chat Escalations]
      summary: Send a chat escalation message
      description: |
        Required scope: `chat_escalations:write`. Owner-only write requiring
        Idempotency-Key. A non-owner or state conflict returns 409.
      x-required-scopes: [chat_escalations:write]
      parameters:
        - $ref: "#/components/parameters/ChatEscalationId"
        - $ref: "#/components/parameters/IdempotencyKeyRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string, minLength: 1 }
              additionalProperties: false
      responses:
        "201": { description: Message sent. }
        "403": { $ref: "#/components/responses/ChatEscalationForbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
  /chat-escalations/{id}/claim:
    post:
      tags: [Chat Escalations]
      summary: Atomically claim a chat escalation
      description: |
        Required scope: `chat_escalations:write`. Accepts no caller identity;
        ownership is atomically bound to the eligible staff user who owns the
        key. Naturally retry-safe without Idempotency-Key.
      x-required-scopes: [chat_escalations:write]
      parameters:
        - $ref: "#/components/parameters/ChatEscalationId"
      responses:
        "200": { description: Claimed escalation (or the same owner's retry). }
        "403": { $ref: "#/components/responses/ChatEscalationForbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
  /chat-escalations/{id}/resolve:
    post:
      tags: [Chat Escalations]
      summary: Resolve a chat escalation
      description: |
        Required scope: `chat_escalations:write`. Owner-only write requiring
        Idempotency-Key. `resolutionNotes` is optional. A non-owner or state
        conflict returns 409.
      x-required-scopes: [chat_escalations:write]
      parameters:
        - $ref: "#/components/parameters/ChatEscalationId"
        - $ref: "#/components/parameters/IdempotencyKeyRequired"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                resolutionNotes: { type: string }
              additionalProperties: false
      responses:
        "200": { description: Resolved escalation. }
        "403": { $ref: "#/components/responses/ChatEscalationForbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  # ─── Service Requests / Ticket Plays ────────────────────────────────
  /plays:
    get:
      tags: [Service Requests]
      summary: List available service plays
      description: |
        Returns the play catalog visible to the caller. Required scope:
        `plays:read`. External callers see only partner-visible plays.
      x-required-scopes: [plays:read]
      parameters:
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        "200":
          description: Paginated play catalog
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PlayListResponse" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /plays/{playKey}:
    get:
      tags: [Service Requests]
      summary: Read a service play
      description: |
        Returns the caller-visible play definition. Required scope:
        `plays:read`. The detail includes the role owner, ordered human steps,
        approved CoverCore operation IDs, and definition of done. It contains
        no staff PII: no staff names, email addresses, user IDs, or assignment
        metadata.
      x-required-scopes: [plays:read]
      parameters:
        - $ref: "#/components/parameters/PlayKey"
      responses:
        "200":
          description: Play detail without staff PII
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PlayDetail" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /service-tickets/{id}/play:
    get:
      tags: [Service Requests]
      summary: Read the play attached to a service ticket
      description: |
        Returns the attached play and its runs for a caller-visible ticket.
        Required scope: `plays:read`. Play detail and run output contain no
        staff PII. A play run never closes or otherwise resolves the ticket;
        ticket closure remains an explicit human-owned action.
      x-required-scopes: [plays:read]
      parameters:
        - $ref: "#/components/parameters/ServiceTicketId"
      responses:
        "200":
          description: Attached play and run history
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/TicketPlay" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /service-tickets/{id}/play-runs:
    post:
      tags: [Service Requests]
      summary: Start a play run for a service ticket (first-party only)
      description: |
        Starts or attaches a run on the ticket. Required scope:
        `play_runs:write`. This operation is restricted to first-party
        principals (partnerType admin/internal/internal_system/ai/integration);
        external keys receive 403 regardless of grants. Starting or completing
        a run never closes the service ticket.
      x-required-scopes: [play_runs:write]
      x-first-party-only: true
      parameters:
        - $ref: "#/components/parameters/ServiceTicketId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InternalPlayRunRequest" }
      responses:
        "201":
          description: Play run created; ticket status is unchanged
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PlayRun" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /plays/{playKey}/start:
    post:
      tags: [Service Requests]
      summary: Start a partner-visible play (external only)
      description: |
        External partner entry point for the partner-visible `coi-proof` and
        `loss-runs` plays. Required scope: `plays:start`. First-party callers
        must use `POST /service-tickets/{id}/play-runs` instead. The required
        Idempotency-Key makes retries safe. It creates a granular real service
        ticket or safely attaches the play to an in-book deduplicated ticket,
        returning a safe ticket result and never a PlayRun.
      x-required-scopes: [plays:start]
      x-external-only: true
      parameters:
        - $ref: "#/components/parameters/PlayKey"
        - $ref: "#/components/parameters/IdempotencyKeyRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PartnerPlayStartRequest" }
      responses:
        "201":
          description: A new real service ticket was created and the play attached
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/PartnerStartedTicket" }
                  created: { type: boolean, enum: [true] }
                  deduplicated: { type: boolean, enum: [false] }
        "200":
          description: The play was safely attached to an in-book deduplicated real ticket
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerPlayStartResponse" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  # ─── Agent policyholder intake ──────────────────────────────────────
  /agencies/resolve-ownership:
    post:
      tags: [Agencies, Policyholder]
      summary: Resolve carrier portal ownership to an existing agency and contact
      description: |
        Requires `policies:write`. Resolves supported carrier producer codes or
        exact submitting-agent names to existing CoverCore records. It never
        creates or guesses agencies or contacts. Unresolved or ambiguous input
        returns 422 with candidates when available.
      x-required-scopes: [policies:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OwnershipFrom" }
      responses:
        "200":
          description: Existing agency and contact resolved
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/OwnershipResolution" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: Ownership unresolved or ambiguous; no write occurred
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /policyholders:
    get:
      tags: [Policyholder]
      summary: List policyholders in the authorized book
      description: |
        Requires `policies:read`. Results are filtered server-side to the
        authenticated agent's proven book of business (agency membership does
        not expose sibling-agent records). Each result includes `ownership`
        and `externalRefs`; clients must not infer ownership from a search
        match or external reference.
      x-required-scopes: [policies:read]
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PolicyholderLimit"
        - name: search
          in: query
          required: false
          schema: { type: string, maxLength: 255 }
          description: Case-insensitive company-name, email, or policy-number search.
      responses:
        "200":
          description: Authorized policyholder collection
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerPolicyholderListResponse" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Policyholder]
      summary: Create an agent-owned policyholder
      description: |
        Requires `policies:write`. Agent keys automatically inject their bound
        agency and contact when ownership is omitted; supplied ownership must
        match that binding. Admin/internal keys must provide either `ownership`
        or `ownershipFrom`. `ownership.agencyId` accepts agencies.id or ptnUuid;
        `ownership.agentId` is contacts.id. CoverCore creates the record and returns
        it immediately as a CoverCore-only shell with `syncStatus: local_only`
        and `actId: null`. No automatic Jarvis ACT create/writeback currently
        occurs and CoverCore never invents a Jarvis identifier.
        `externalRefs` are caller-supplied correlation values; `source` is
        normalized to lowercase and `externalId` is preserved. If
        `opportunityId` is supplied, it must identify
        an existing, locked opportunity whose direct owner is proven. The link
        is stored in opportunity metadata and the policyholder's
        `opportunityId`; no separate opportunity API is exposed.
      x-required-scopes: [policies:write]
      x-error-codes:
        [VALIDATION_ERROR, AUTHENTICATION_REQUIRED, INVALID_API_KEY,
          INSUFFICIENT_SCOPE, FORBIDDEN, AGENT_KEY_UNBOUND,
          OWNERSHIP_INVALID, OWNERSHIP_UNRESOLVED, NOT_FOUND,
         OPPORTUNITY_OWNER_UNRESOLVED, OPPORTUNITY_OWNERSHIP_MISMATCH,
         EXTERNAL_REF_CONFLICT, IDEMPOTENCY_KEY_REQUIRED,
         OPPORTUNITY_LINK_CONFLICT, IDEMPOTENCY_CONFLICT,
         IDEMPOTENCY_IN_PROGRESS, IDEMPOTENCY_UNAVAILABLE,
         IDEMPOTENCY_RETRY_REQUIRED, INTERNAL_ERROR]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKeyRequired"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PolicyholderCreateRequest" }
      responses:
        "200":
          description: Existing directly owned policyholder upserted by external reference
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PolicyholderMutationResponse" }
        "201":
          description: CoverCore-only policyholder shell created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PolicyholderMutationResponse" }
        "400":
          description: Validation failure, including `IDEMPOTENCY_KEY_REQUIRED`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Referenced agency, agent, or opportunity does not exist (`NOT_FOUND`)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422":
          description: Portal ownership input is unresolved or ambiguous
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Idempotency service unavailable or retry required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /policyholders/{id}:
    get:
      tags: [Policyholder]
      summary: Read an agent-owned policyholder
      description: |
        Requires `policies:read`. The record is returned only when direct
        immutable ownership by the authenticated agent is proven. Agency
        membership, a matching external reference, or an opportunity link
        does not establish ownership. A missing or non-owned identifier
        returns the same `404 NOT_FOUND` response.
      x-required-scopes: [policies:read]
      x-error-codes:
        [AUTHENTICATION_REQUIRED, INVALID_API_KEY, INSUFFICIENT_SCOPE,
         NOT_FOUND, INTERNAL_ERROR]
      parameters:
        - $ref: "#/components/parameters/OwnedResourceId"
      responses:
        "200":
          description: Policyholder detail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerPolicyholderResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Policyholder is missing or not owned by this agent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /policyholders/{id}/ownership:
    patch:
      tags: [Policyholder]
      summary: Transfer policyholder ownership in place
      description: |
        Atomically changes only the existing policyholder's agency and agent
        ownership. Name, external references, opportunity linkage, and partner
        metadata are retained. The target agent must be an existing member of
        the target agency. First-party admin/internal keys may transfer across
        books; agency and agent keys remain restricted to their proven book and
        immutable key binding.

        Requires `policyholders:write`. Existing integrations holding the
        legacy `policies:write` scope are accepted as a compatibility alias for
        this operation only. Either the `Idempotency-Key` header or body
        `idempotencyKey` is required; when both are sent they must match.
        Identical completed retries are authorized against the policyholder's
        current book and return its current full representation read-only; they
        never re-apply an older ownership target.
      x-required-scopes: [policyholders:write]
      x-compatible-scopes: [policies:write]
      x-error-codes:
        [VALIDATION_ERROR, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_MISMATCH,
         IDEMPOTENCY_CONFLICT, IDEMPOTENCY_IN_PROGRESS,
         IDEMPOTENCY_UNAVAILABLE, INSUFFICIENT_SCOPE, FORBIDDEN,
         POLICYHOLDER_NOT_FOUND, AGENCY_NOT_FOUND, AGENT_NOT_FOUND,
         OWNERSHIP_INVALID, OWNERSHIP_UNRESOLVED, INTERNAL_ERROR]
      parameters:
        - $ref: "#/components/parameters/OwnedResourceId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PolicyholderOwnershipTransferRequest" }
      responses:
        "200":
          description: Ownership transferred or already identical; current policyholder returned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PartnerPolicyholderResponse" }
        "400":
          description: Invalid body or missing/mismatched idempotency key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Policyholder, agency, or agent was not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422":
          description: Target agency identifier is ambiguous
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Idempotency protection unavailable
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ─── Policyholder self-service ─────────────────────────────────────
  /my:
    get:
      tags: [Policyholder]
      summary: Inspect policyholder identity
      description: Alias of `GET /my/identity`. The policyholder is derived only from the API key.
      x-required-scopes: []
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderIdentity" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /my/identity:
    get:
      tags: [Policyholder]
      summary: Inspect policyholder identity and effective scopes
      description: Returns safe key metadata and the immutable policyholder identity selected by the credential.
      x-required-scopes: []
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderIdentity" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /my/profile:
    get:
      tags: [Policyholder]
      summary: Read the policyholder profile
      description: "Required scope: `policies:read`."
      x-required-scopes: [policies:read]
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: Profile
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PolicyholderProfileResponse" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /my/policies:
    get:
      tags: [Policyholder]
      summary: List policies
      description: >-
        Required scope: `policies:read`. The policyholder is selected exclusively by
        the API key. When omitted, this endpoint preserves its established legacy
        coverage-row representation and legacy IDs. `selection=latest` is an additive
        clean-book projection with one latest-known term per policy number and related
        coverages; `selection=history` is an additive term-snapshot projection. A reported
        premium is returned only when one source
        amount unambiguously supports that selected term; it is never a sum of transaction
        rows. Net agency premiums, commissions, and wholesale controls are not exposed.
        Clean-book projections group the complete owned source authority before applying
        `updated_since` filtering and pagination. A current complete base term takes
        priority over a future renewal. Active status requires verified base-term
        evidence with both effective and expiration dates; a single date is insufficient.
      x-required-scopes: [policies:read]
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PolicyholderLimit"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/PolicyBookSelection"
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderPolicyCollection" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /my/policies/{id}:
    get:
      tags: [Policyholder]
      summary: Read policy detail
      description: "Required scope: `policies:read`. Only a policy owned by the key's policyholder can be returned. Omit `selection` for the established legacy detail representation; legacy row IDs and clean-book stable IDs are accepted as aliases. Pass `selection=history` only for an ID returned by the historical policy list."
      x-required-scopes: [policies:read]
      parameters:
        - $ref: "#/components/parameters/OwnedResourceId"
        - $ref: "#/components/parameters/PolicyBookSelection"
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderResource" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /my/orders:
    get:
      tags: [Policyholder]
      summary: List policy order records
      description: "Required scope: `policies:read`. Results are capped at 100 source rows."
      x-required-scopes: [policies:read]
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PolicyholderLimit"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderCollection" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /my/quotes:
    get:
      tags: [Policyholder]
      summary: List quotes
      description: "Required scope: `quotes:read`. Results are capped at 100 source rows."
      x-required-scopes: [quotes:read]
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PolicyholderLimit"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderCollection" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /my/documents:
    get:
      tags: [Policyholder]
      summary: List document metadata
      description: "Required scope: `documents:read`. Only explicitly owned, insured-facing documents are returned. Audience filtering precedes pagination and totals; sourceCap is null and sourceTruncated is false. Internal correspondence and ambiguous legacy files are excluded. policyNumber is null unless supported by an owned policy or authentic issued-package provenance. downloadUrl is null when no supported stored file reference is on record; a stored reference does not guarantee the bytes remain retrievable."
      x-required-scopes: [documents:read]
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PolicyholderLimit"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/IfNoneMatch"
        - name: document_type
          in: query
          schema: { type: string, minLength: 1 }
          description: Exact document-type filter.
      responses:
        "200": { $ref: "#/components/responses/PolicyholderCollection" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /my/documents/{id}:
    get:
      tags: [Policyholder]
      summary: Read document detail
      description: "Required scope: `documents:read`. The same ownership and insured-facing audience gate applies to detail and download. Metadata includes the authenticated download URL, or null when no supported stored file reference is on record."
      x-required-scopes: [documents:read]
      parameters:
        - $ref: "#/components/parameters/OwnedResourceId"
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderResource" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /my/documents/{id}/download:
    get:
      tags: [Policyholder]
      summary: Download document bytes
      description: "Required scope: `documents:read`. Returns a private, non-cacheable attachment and never redirects to an unauthenticated object URL."
      x-required-scopes: [documents:read]
      parameters:
        - $ref: "#/components/parameters/OwnedResourceId"
      responses:
        "200":
          description: Document attachment
          headers:
            Content-Disposition:
              schema: { type: string }
            X-Content-Type-Options:
              schema: { type: string, const: nosniff }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "503":
          description: Document temporarily unavailable; retry later
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
  /my/agent:
    get:
      tags: [Policyholder]
      summary: Read the servicing agent's public contact details
      description: "Required scope: `agents:read`. `data` is null when no agent is linked."
      x-required-scopes: [agents:read]
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderResource" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /my/claims:
    get:
      tags: [Policyholder]
      summary: List claims
      description: "Required scope: `claims:read`. Results are capped at 100 source rows."
      x-required-scopes: [claims:read]
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PolicyholderLimit"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderCollection" }
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /my/prevention:
    get:
      tags: [Policyholder]
      summary: Read prevention-service destinations
      description: "Required scope: `prevention:read`."
      x-required-scopes: [prevention:read]
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200": { $ref: "#/components/responses/PolicyholderResource" }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /studioguard/policy-forms/{formId}/authoritative-pdfs:
    parameters:
      - name: formId
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
    get:
      operationId: getPolicyFormAuthoritativePdfs
      tags: [Policy Forms]
      summary: Read the current authoritative PDF pairing and history
      description: |
        First-party only. Requires the exact `policy_forms:authoritative_pdf_read`
        scope; `policy_forms:read`, write, or approval scopes do not imply it.
        Download URLs in this response point back to this scoped Platform API.
      security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
      responses:
        "200":
          description: Current exact-source pairing and immutable history
          headers:
            Cache-Control: { schema: { type: string, const: no-store } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthoritativePdfStatus" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
    post:
      operationId: acceptPolicyFormAuthoritativePdf
      tags: [Policy Forms]
      summary: Pair or replace an authoritative PDF
      description: |
        First-party only and requires the exact
        `policy_forms:authoritative_pdf_write` scope. This operation never grants
        approval authority. It binds the uploaded bytes to the exact current
        source document ID and SHA-256, uses `expectedPairingId` as compare-and-swap
        (`none` means no current pairing), retains replaced pairings as history,
        and suspends prior approval pending the normal four-step review.

        Send `multipart/form-data` with one PDF (maximum 20 MiB). The PDF must
        be structurally safe, unencrypted, contain no active content, and have
        printed form-number/edition identity matching the current source.
        A 202 means the pairing committed but post-commit foundation preparation
        failed; callers must refresh authority and must not assume rollback.
      security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/AuthoritativePdfInput" }
      responses:
        "201":
          description: Pairing committed and foundation prepared; no approval granted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthoritativePdfMutationResult" }
        "202":
          description: Pairing committed, approval suspended, but post-commit foundation preparation failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthoritativePdfPostCommitReceipt" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413":
          description: PDF exceeds 20 MiB
        "422": { $ref: "#/components/responses/ValidationError" }
  /studioguard/policy-forms/{formId}/authoritative-pdfs/native-source:
    parameters:
      - name: formId
        in: path
        required: true
        schema: { type: integer, minimum: 1 }
    post:
      operationId: acceptPolicyFormNativePdfSource
      tags: [Policy Forms]
      summary: Create or replace a native PDF source and authoritative pairing
      description: |
        PDF-first authoring path. The selected PDF becomes both source identity
        and exact geometry evidence; no DOCX is required or fabricated. Existing
        source and pairing evidence is retained as superseded lineage and all
        four-step approval must be completed again.
      security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              additionalProperties: false
              required: [file, expectedPairingId, confirmPairing]
              properties:
                file: { type: string, format: binary }
                expectedPairingId: { type: string, pattern: "^(none|[1-9][0-9]*)$" }
                confirmPairing: { type: string, const: "true" }
                note: { type: string, maxLength: 1000 }
      responses:
        "201":
          description: Native PDF source and pairing committed; no approval granted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthoritativePdfMutationResult" }
        "202":
          description: PDF source and pairing committed but foundation preparation failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthoritativePdfPostCommitReceipt" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { description: PDF exceeds 20 MiB }
        "422": { $ref: "#/components/responses/ValidationError" }
  /studioguard/policy-forms/{formId}/authoritative-pdfs/{pairingId}/download:
    get:
      operationId: downloadPolicyFormAuthoritativePdf
      tags: [Policy Forms]
      summary: Download verified authoritative PDF bytes
      description: |
        Downloads a current or historical pairing only after exact size and
        SHA-256 readback verification. First-party only; requires the exact
        `policy_forms:authoritative_pdf_read` scope.
      security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
      parameters:
        - name: formId
          in: path
          required: true
          schema: { type: integer, minimum: 1 }
        - name: pairingId
          in: path
          required: true
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: Verified original PDF bytes
          headers:
            Content-Disposition: { schema: { type: string } }
            Cache-Control: { schema: { type: string, const: "private, no-store" } }
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  # These browser-session endpoints issue and manage the signed-in user's
  # credentials. They are intentionally outside the /api/v1 API-key surface.
  /api/platform/my-api-keys:
    servers:
      - url: https://covercore.services
        description: Production web application
    get:
      tags: [Credential Self-Service]
      summary: List the signed-in user's API keys
      description: For policyholder sessions, returns only policyholder-scoped keys whose immutable policyholder ID matches the account currently linked to the session.
      security: [{ CookieSession: [] }]
      responses:
        "200":
          description: Safe key metadata; secret values are never returned
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/SelfServiceApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Credential Self-Service]
      summary: Issue an API key
      description: |
        Requires an authenticated browser session. For a policyholder account,
        authority is linked server-side, requested scopes must be a subset of
        the six policyholder read scopes, and omitted scopes grant that complete
        set. `fullKey` is disclosed exactly once in the 201 response.
      security: [{ CookieSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SelfServiceApiKeyCreate" }
      responses:
        "201":
          description: Key issued; store fullKey securely because it cannot be recovered
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SelfServiceApiKey"
                  - type: object
                    required: [fullKey]
                    properties:
                      fullKey:
                        type: string
                        readOnly: true
                        description: One-time credential value. Returned only by this creation response and never recoverable later.
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /api/platform/my-api-keys/{id}:
    servers:
      - url: https://covercore.services
        description: Production web application
    get:
      tags: [Credential Self-Service]
      summary: Inspect an owned API key
      description: Returns safe metadata only. The one-time secret and key hash are never returned. Policyholder sessions receive 404 for legacy, unscoped, or differently linked keys even when partnerId matches the user.
      security: [{ CookieSession: [] }]
      parameters:
        - $ref: "#/components/parameters/OwnedResourceId"
      responses:
        "200":
          description: Safe metadata for the owned key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SelfServiceApiKey" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Credential Self-Service]
      summary: Update an owned API key
      description: Policyholder sessions may update non-authority metadata only; authority fields, scopes, and rate limits are immutable and rejected when supplied.
      security: [{ CookieSession: [] }]
      parameters:
        - $ref: "#/components/parameters/OwnedResourceId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SelfServiceApiKeyUpdate" }
      responses:
        "200":
          description: Updated safe key metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SelfServiceApiKey" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Credential Self-Service]
      summary: Revoke an owned API key
      security: [{ CookieSession: [] }]
      parameters:
        - $ref: "#/components/parameters/OwnedResourceId"
      responses:
        "200":
          description: Key revoked
          content:
            application/json:
              schema:
                type: object
                required: [success, id, isActive]
                properties:
                  success: { type: boolean, const: true }
                  id: { type: string }
                  isActive: { type: boolean, const: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

components:
  x-authoritative-pdf-schemas: &authoritativePdfSchemas
    AuthoritativePdfInput: &authoritativePdfInput
      type: object
      additionalProperties: false
      required:
        [file, expectedSourceDocumentId, expectedSourceSha256, expectedPairingId, confirmPairing]
      properties:
        file:
          type: string
          format: binary
          description: One `.pdf` file, at most 20 MiB.
        expectedSourceDocumentId:
          type: integer
          minimum: 1
        expectedSourceSha256:
          type: string
          pattern: "^[a-f0-9]{64}$"
        expectedPairingId:
          type: string
          pattern: "^(none|[1-9][0-9]*)$"
          description: Current pairing ID, or `none` when no pairing currently exists.
        confirmPairing:
          type: string
          const: "true"
          description: Explicitly confirms exact-source pairing and four-step reapproval reset.
        note:
          type: string
          maxLength: 1000
    AuthoritativePdfSource: &authoritativePdfSource
      type: object
      required: [id, sha256, originalFilename, mimeType, sourceKind]
      properties:
        id: { type: integer }
        sha256: { type: string, pattern: "^[a-f0-9]{64}$" }
        originalFilename: { type: string }
        mimeType: { type: string }
        sourceKind: { type: string, enum: [docx, native_pdf] }
    AuthoritativePdfPairing: &authoritativePdfPairing
      type: object
      required:
        [id, status, sourceDocumentId, sourceSha256, originalFilename, mimeType,
         fileSizeBytes, pdfSha256, pageCount, pageSizesPt, normalizedFormNumber,
         identityEvidencePages, acceptedBy, acceptedByRole, acceptedAt,
         supersedesPairingId, downloadUrl]
      properties:
        id: { type: integer }
        status: { type: string, enum: [current, superseded] }
        sourceDocumentId: { type: integer }
        sourceSha256: { type: string, pattern: "^[a-f0-9]{64}$" }
        originalFilename: { type: string }
        mimeType: { type: string, const: application/pdf }
        fileSizeBytes: { type: integer }
        pdfSha256: { type: string, pattern: "^[a-f0-9]{64}$" }
        pageCount: { type: integer }
        pageSizesPt:
          type: array
          items:
            type: array
            prefixItems: [{ type: number }, { type: number }]
            minItems: 2
            maxItems: 2
        normalizedFormNumber: { type: string }
        normalizedEdition: { type: ["string", "null"] }
        identityEvidencePages:
          type: array
          items: { type: integer }
        acceptedBy: { type: string }
        acceptedByRole: { type: string }
        acceptedAt: { type: ["string", "null"], format: date-time }
        note: { type: ["string", "null"] }
        supersedesPairingId: { type: ["integer", "null"] }
        downloadUrl: { type: string }
    AuthoritativePdfStatus: &authoritativePdfStatus
      type: object
      required:
        [schema, formId, sourceDocument, current, history, authoringResetRequired]
      properties:
        schema: { type: string, const: governed_authoritative_pdf_pairing_v1 }
        formId: { type: integer }
        sourceDocument:
          anyOf:
            - $ref: "#/components/schemas/AuthoritativePdfSource"
            - type: "null"
        current:
          anyOf:
            - $ref: "#/components/schemas/AuthoritativePdfPairing"
            - type: "null"
        history:
          type: array
          items: { $ref: "#/components/schemas/AuthoritativePdfPairing" }
        authoringResetRequired: { type: boolean }
    AuthoritativePdfMutationResult: &authoritativePdfMutationResult
      allOf:
        - $ref: "#/components/schemas/AuthoritativePdfStatus"
        - type: object
          required: [pairingCommitted, foundationPreparation, approvalGranted]
          properties:
            pairingCommitted: { type: boolean, const: true }
            foundationPreparation: { type: string, enum: [ready, failed] }
            approvalGranted: { type: boolean, const: false }
            preparationError:
              type: object
              required: [code, message]
              properties:
                code: { type: string }
                message: { type: string }
    AuthoritativePdfPostCommitReceipt: &authoritativePdfPostCommitReceipt
      type: object
      required:
        [formId, committedPairingId, pairingCommitted, foundationPreparation,
         statusRefresh, approvalGranted, preparationError]
      properties:
        formId: { type: integer }
        committedPairingId:
          type: integer
          description: Exact pairing inserted by this request; never inferred from file hash.
        pairingCommitted: { type: boolean, const: true }
        foundationPreparation: { type: string, const: failed }
        statusRefresh: { type: string, enum: [ready, failed] }
        approvalGranted: { type: boolean, const: false }
        status:
          $ref: "#/components/schemas/AuthoritativePdfStatus"
          description: Optional refreshed status; it may show a newer concurrent selection.
        preparationError:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: pk_live_/pk_test_
      description: API credentials use `pk_live_*` or `pk_test_*`. Grants are additionally checked against each operation's scopes and any restricted-principal route/method matrix.
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Supply the same one-time `pk_live_*` or `pk_test_*` credential. Grants are additionally checked against scopes and any restricted-principal route/method matrix.
    CookieSession:
      type: apiKey
      in: cookie
      name: connect.sid
      description: CoverCore browser session. Used only by credential self-service routes; API keys cannot call them.
  parameters:
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
      description: One-based page number.
    PolicyholderLimit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      description: Page size; the maximum is 100.
    UpdatedSince:
      name: updated_since
      in: query
      schema: { type: string, format: date-time }
      description: Include rows updated at or after this RFC 3339 timestamp. A timezone is required.
    PolicyBookSelection:
      name: selection
      in: query
      schema:
        type: string
        enum: [latest, history]
      description: "Optional additive policy-book projection. `latest` returns one latest-known term per policy number; `history` returns term snapshots. When omitted, the established legacy coverage-row representation and IDs are returned. This never changes key-derived ownership."
    IfNoneMatch:
      name: If-None-Match
      in: header
      schema: { type: string }
      description: Send a previously returned ETag. An unchanged representation returns 304 with no body.
    OwnedResourceId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Opaque resource ID. Ownership is enforced from the credential or session.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, maxLength: 200 }
      description: |
        Optional UUID. Successful (2xx) responses are cached for 24h
        keyed by `(api_key_id, idempotency_key)`. Replays return the
        same body with `Idempotent-Replayed: true`.
    IdempotencyKeyRequired:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 1, maxLength: 200 }
      description: |
        Required where referenced. Reuse the same value when retrying the same
        logical request; a conflicting payload returns 409.
    PlayKey:
      name: playKey
      in: path
      required: true
      schema: { type: string, pattern: "^[a-z0-9][a-z0-9-]*$" }
      description: Stable play key (for example, `coi-proof` or `loss-runs`).
    ServiceTicketId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Service ticket ID visible to the caller.
    ChatEscalationId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Chat escalation ID.
  responses:
    PolicyholderIdentity:
      description: Immutable policyholder identity and safe credential metadata
      headers:
        ETag: { $ref: "#/components/headers/ETag" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/PolicyholderIdentityResponse" }
    PolicyholderCollection:
      description: Policyholder-owned collection
      headers:
        ETag: { $ref: "#/components/headers/ETag" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/PolicyholderCollectionResponse" }
    PolicyholderPolicyCollection:
      description: Policyholder-owned policy book
      headers:
        ETag: { $ref: "#/components/headers/ETag" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/PolicyholderPolicyCollectionResponse" }
    PolicyholderResource:
      description: Policyholder-owned resource
      headers:
        ETag: { $ref: "#/components/headers/ETag" }
      content:
        application/json:
          schema:
            type: object
            required: [data]
            properties:
              data:
                oneOf:
                  - { $ref: "#/components/schemas/PolicyholderResource" }
                  - { type: "null" }
    NotModified:
      description: Representation unchanged; response has no body
      headers:
        ETag: { $ref: "#/components/headers/ETag" }
    Unauthorized:
      description: Missing/invalid API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Insufficient scope or cross-tenant access
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ChatEscalationForbidden:
      description: |
        Chat escalation eligibility failure: `LIVE_KEY_REQUIRED` for sandbox,
        `FIRST_PARTY_KEY_REQUIRED` for external/agent-scoped/unknown partner
        types, or `STAFF_OWNER_REQUIRED` for a missing, deleted, or ineligible
        key owner. Policyholder-scoped keys are rejected earlier with
        `POLICYHOLDER_KEY_RESTRICTED`. Audit metadata records browser vs
        platform_api, staff ID, and safe key name/prefix, never the credential.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Race or idempotency conflict
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnprocessableEntity:
      description: Business-rule violation (e.g. AMOUNT_EXCEEDS_BALANCE)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: Request body failed Zod validation
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ExternalQuoteImportValidationError:
      description: |
        Import or submission-filter validation failed. Each `fieldErrors`
        entry has a full nested `path`; bulk paths preserve the zero-based item
        index (for example, `quotes.3.ownership.contactId`) rather than
        collapsing a single/bulk union error into an empty object.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ExternalQuoteImportValidationErrorResponse" }
    EvaluationRequired:
      description: |
        Returned by every `/rate/*` route when `evaluation_id` is
        missing, stale, cross-tenant, or in a non-accepted state. The
        canonical code is `EVALUATION_REQUIRED` and the body's
        `details.reason` is one of `missing_evaluation_id`,
        `not_found`, `wrong_partner`, `environment_mismatch`,
        `not_approved`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  headers:
    ETag:
      schema: { type: string }
      description: Strong validator for this authenticated representation. Responses also vary on Authorization and X-API-Key.
  schemas:
    ThumbtackSearchInput:
      type: object
      required: [zipCode]
      anyOf: [{required: [categoryId]}, {required: [query]}]
      properties:
        zipCode: {type: string, pattern: '^[0-9]{5}$', description: Must exist in the US ZIP dataset}
        categoryId: {type: string, pattern: '^[a-zA-Z0-9_-]{1,100}$'}
        query: {type: string, minLength: 1, maxLength: 200}
        limit: {type: integer, minimum: 1, maximum: 20, default: 10}
    ThumbtackQuickSearchInput:
      type: object
      required: [zipCode]
      properties:
        zipCode: {type: string, pattern: '^[0-9]{5}$'}
        limit: {type: integer, minimum: 1, maximum: 20, default: 10}
    ThumbtackSettingsInput:
      type: object
      properties:
        settings: {type: array, items: {type: object}}
        sync_config: {type: object}
    ThumbtackLeadInput:
      type: object
      required: [businesses, category_id, zip_code]
      properties:
        category_id: {type: string}
        zip_code: {type: string, pattern: '^[0-9]{5}$'}
        user_id: {type: string, description: Ignored; owner is the authenticated key creator}
        businesses: {type: array, minItems: 1, maxItems: 100, items: {type: object, required: [businessID, businessName], properties: {businessID: {type: string}, businessName: {type: string}}}}
    AuthoritativePdfInput: *authoritativePdfInput
    AuthoritativePdfSource: *authoritativePdfSource
    AuthoritativePdfPairing: *authoritativePdfPairing
    AuthoritativePdfStatus: *authoritativePdfStatus
    AuthoritativePdfMutationResult: *authoritativePdfMutationResult
    AuthoritativePdfPostCommitReceipt: *authoritativePdfPostCommitReceipt
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, retryable]
          properties:
            code: { type: string, example: EVALUATION_REQUIRED }
            message: { type: string }
            retryable:
              type: boolean
              description: True when retrying later may succeed (408, 409, 429, and 5xx).
            details: {}
    PolicyholderIdentityResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [identity, key, effectiveScopes]
          properties:
            identity:
              type: object
              required: [type, id]
              properties:
                type: { type: string, const: policyholder }
                id: { type: string }
                displayName: { type: [string, "null"] }
                email: { type: [string, "null"], format: email }
            key:
              type: object
              required: [id, keyPrefix, environment, partnerType, policyholderScoped]
              properties:
                id: { type: string }
                keyPrefix: { type: string }
                environment: { type: string, enum: [live, sandbox] }
                partnerType: { type: string, const: policyholder }
                policyholderScoped: { type: boolean, const: true }
            effectiveScopes:
              type: array
              items:
                type: string
                enum: [policies:read, quotes:read, documents:read, agents:read, claims:read, prevention:read]
    PolicyholderProfileResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [id, address]
          properties:
            id: { type: string }
            displayName: { type: [string, "null"] }
            email: { type: [string, "null"], format: email }
            phone: { type: [string, "null"] }
            address:
              type: object
              properties:
                street: { type: [string, "null"] }
                city: { type: [string, "null"] }
                state: { type: [string, "null"] }
                postalCode: { type: [string, "null"] }
            industry: { type: [string, "null"] }
            businessDescription: { type: [string, "null"] }
            website: { type: [string, "null"] }
            effectiveDate: { type: [string, "null"] }
            updatedAt: { type: [string, "null"], format: date-time }
    PolicyholderCreateRequest:
      type: object
      required: [name]
      additionalProperties: false
      description: |
        Agent keys may omit both ownership fields and use the key's immutable
        agency/contact binding. Other callers must provide ownership or ownershipFrom.
      properties:
        name: { type: string, minLength: 1, maxLength: 255 }
        dba: { type: [string, "null"], maxLength: 255 }
        email: { type: [string, "null"], format: email, maxLength: 255 }
        phone: { type: [string, "null"], maxLength: 50 }
        mailingAddress:
          type: [object, "null"]
          additionalProperties: false
          properties:
            line1: { type: [string, "null"], maxLength: 255 }
            city: { type: [string, "null"], maxLength: 120 }
            state: { type: [string, "null"], maxLength: 120 }
            postalCode: { type: [string, "null"], maxLength: 30 }
            country: { type: [string, "null"], enum: [US, null] }
        ownership:
          type: object
          additionalProperties: false
          required: [agencyId, agentId]
          properties:
            agencyId: { type: string }
            agentId:
              type: string
              description: Canonical contacts.id; agents.id and contacts.conId are not accepted.
        ownershipFrom: { $ref: "#/components/schemas/OwnershipFrom" }
        externalRefs:
          type: array
          maxItems: 20
          description: Optional idempotent upsert references.
          items:
            type: object
            additionalProperties: false
            required: [source, externalId]
            properties:
              source: { type: string, minLength: 1, maxLength: 120 }
              externalId: { type: string, minLength: 1, maxLength: 255 }
        opportunityId: { type: [string, "null"] }
        metadata:
          type: object
          additionalProperties: true
          description: Partner metadata; serialized JSON is limited to 16,000 bytes.
    PolicyholderOwnershipTransferRequest:
      type: object
      additionalProperties: false
      required: [agencyId, agentId, reason]
      properties:
        agencyId:
          type: string
          minLength: 1
          maxLength: 255
          description: Existing agencies.id or its exact ptnUuid.
        agentId:
          type: string
          minLength: 1
          maxLength: 255
          description: Existing canonical contacts.id belonging to agencyId.
        reason:
          type: string
          minLength: 3
          maxLength: 500
          description: Durable audit reason for the ownership change.
        idempotencyKey:
          type: string
          minLength: 1
          maxLength: 200
          description: May replace the Idempotency-Key header; must match it when both are sent.
    OwnershipFrom:
      type: object
      additionalProperties: false
      required: [carrier]
      properties:
        carrier: { type: string, example: travelers }
        producer_or_agency_code: { type: [string, "null"], example: X1681 }
        submitting_agent_name: { type: [string, "null"], example: Stuckey And Co }
        fallback_agency_id: { type: [string, "null"] }
        fallback_contact_id: { type: [string, "null"] }
    OwnershipResolution:
      type: object
      additionalProperties: false
      required: [agencyId, agencyPtnUuid, contactId, confidence, matched, warnings, candidates]
      properties:
        agencyId: { type: string, description: Canonical agencies.id }
        agencyPtnUuid: { type: [string, "null"] }
        contactId: { type: string, description: Canonical contacts.id }
        confidence: { type: string, enum: [exact, fallback] }
        matched:
          type: object
          additionalProperties: false
          required: [agency, contact]
          properties:
            agency: { type: [string, "null"] }
            contact: { type: [string, "null"] }
        warnings:
          type: array
          items: { type: string }
        candidates:
          type: array
          items: { type: object, additionalProperties: true }
    PolicyholderMutationResponse:
      type: object
      required: [data]
      properties:
        data:
          allOf:
            - $ref: "#/components/schemas/PartnerPolicyholder"
            - type: object
              required: [syncStatus, actId]
              properties:
                syncStatus: { type: string, const: local_only }
                actId: { type: "null" }
    PartnerPolicyholderResponse:
      type: object
      required: [data]
      properties:
        data: { $ref: "#/components/schemas/PartnerPolicyholder" }
    PartnerPolicyholder:
      type: object
      additionalProperties: false
      required: [id, name, dba, email, phone, mailingAddress, ownership, externalRefs, opportunityId, metadata, syncStatus, actId]
      properties:
        id: { type: string }
        name: { type: string }
        dba: { type: [string, "null"] }
        email: { type: [string, "null"], format: email }
        phone: { type: [string, "null"] }
        mailingAddress:
          type: [object, "null"]
          additionalProperties: false
          required: [line1, city, state, postalCode, country]
          properties:
            line1: { type: [string, "null"] }
            city: { type: [string, "null"] }
            state: { type: [string, "null"] }
            postalCode: { type: [string, "null"] }
            country: { type: [string, "null"] }
        ownership:
          type: object
          additionalProperties: false
          required: [agencyId, agentId]
          properties:
            agencyId: { type: string }
            agentId: { type: string }
        externalRefs:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [source, externalId]
            properties:
              source: { type: string }
              externalId: { type: string }
        opportunityId: { type: [string, "null"] }
        metadata: { type: [object, "null"], additionalProperties: true }
        syncStatus: { type: string }
        actId: { type: [string, "null"] }
    PartnerPolicyholderListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/PartnerPolicyholderListItem" }
        pagination:
          type: object
          required: [page, limit, total, totalPages]
          additionalProperties: false
          properties:
            page: { type: integer, minimum: 1 }
            limit: { type: integer, minimum: 1, maximum: 100 }
            total: { type: integer, minimum: 0 }
            totalPages: { type: integer, minimum: 0 }
    PartnerPolicyholderListItem:
      type: object
      allOf:
        - $ref: "#/components/schemas/PartnerPolicyholder"
    PolicyholderResource:
      type: object
      description: Endpoint-specific read-only DTO. Unknown fields are preserved for forward compatibility.
      additionalProperties: true
    PolicyholderCollectionResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/PolicyholderResource" }
        pagination:
          $ref: "#/components/schemas/PolicyholderPagination"
    PolicyholderPolicyCollectionResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/PolicyholderPolicy" }
        pagination:
          $ref: "#/components/schemas/PolicyholderPagination"
        selection:
          type: string
          enum: [latest, history]
    PolicyholderPolicy:
      type: object
      required: [id, source, coverageType, status]
      properties:
        id:
          type: string
          description: Clean projections use stable policy or policy-term identifiers. The unselected legacy representation retains legacy row IDs for compatibility.
        source:
          type: string
          enum: [filemaker, quote_journey]
        applicationReference:
          type: [string, "null"]
          description: Explicit application reference for quote-journey entries; not a policy number.
        policyNumber: { type: [string, "null"] }
        displayPolicyNumber: { type: [string, "null"] }
        coverageType: { type: string }
        displayCoverage: { type: string }
        carrierName: { type: [string, "null"] }
        quotedCarrierName:
          type: [string, "null"]
          description: Carrier named on an application/quote journey. It is not verified policy-carrier evidence.
        effectiveDate: { type: [string, "null"] }
        expirationDate: { type: [string, "null"] }
        premium:
          type: [number, "null"]
          description: A single unambiguous source-reported amount for the selected term; never a calculated balance.
        netPremium:
          type: "null"
          description: Reserved compatibility field. Net agency premium is never disclosed.
        status:
          type: string
          enum: [active, expired, scheduled, cancelled, status_unknown]
        relatedCoverages:
          type: array
          description: Other coverages supported by rows for the same policy number and selected term.
          items:
            type: object
            required: [coverageType, status]
            properties:
              coverageType: { type: string }
              effectiveDate: { type: [string, "null"] }
              expirationDate: { type: [string, "null"] }
              status: { type: string }
        sourceStatus:
          type: string
          enum: [verified, incomplete, sync_mismatch]
          description: Source evidence status for staff/support reconciliation. It does not assert coverage not present in synced source data.
        transactionSummary:
          type: [object, "null"]
          properties:
            count: { type: integer, minimum: 0 }
            adjustmentCount: { type: integer, minimum: 0 }
            grossPremiumActivity: { type: "null" }
            creditsAndReversals: { type: "null" }
            netPremiumActivity: { type: "null" }
        transactions:
          type: array
          description: Present only in the unselected legacy representation. New clean-book list projections omit transaction rows; use owned history resources instead.
          items:
            type: object
            additionalProperties: true
    PolicyholderPagination:
      type: object
      required: [page, limit, total, totalPages, hasPreviousPage, hasNextPage, updatedSince, sourceCap, sourceTruncated]
      properties:
        page: { type: integer, minimum: 1 }
        limit: { type: integer, minimum: 1, maximum: 100 }
        total: { type: integer, minimum: 0 }
        totalPages: { type: integer, minimum: 0 }
        hasPreviousPage: { type: boolean }
        hasNextPage: { type: boolean }
        updatedSince: { type: [string, "null"], format: date-time }
        sourceCap:
          type: [integer, "null"]
          description: Maximum rows considered from each backing source for legacy/raw resources. Null means the clean policy projection grouped the complete owned authority set.
        sourceTruncated:
          type: boolean
          description: True when one or more backing sources had rows beyond the legacy/raw source cap; false with a null sourceCap means the clean policy projection is complete.
    SelfServiceApiKey:
      type: object
      description: Safe API-key metadata. Hashes and recoverable secret material are never included.
      required: [id, partnerId, partnerType, name, description, keyPrefix, environment, scopes, rateLimitPerMinute, rateLimitPerDay, isActive, lastUsedAt, expiresAt, contactEmails, agentScoped, agentUserId, policyholderScoped, policyholderId, createdAt, updatedAt]
      properties:
        id: { type: string }
        partnerId: { type: string }
        partnerType: { type: string }
        name: { type: string }
        description: { type: [string, "null"] }
        keyPrefix: { type: string }
        environment: { type: string, enum: [live, sandbox] }
        scopes:
          type: array
          items: { type: string }
        rateLimitPerMinute: { type: integer }
        rateLimitPerDay: { type: integer }
        expiresAt: { type: [string, "null"], format: date-time }
        isActive: { type: boolean }
        lastUsedAt: { type: [string, "null"], format: date-time }
        contactEmails:
          type: array
          items: { type: string, format: email }
        agentScoped: { type: boolean }
        agentUserId: { type: [string, "null"] }
        policyholderScoped: { type: boolean }
        policyholderId: { type: [string, "null"] }
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }
    SelfServiceApiKeyCreate:
      type: object
      additionalProperties: false
      required: [name, environment]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        environment: { type: string, enum: [live, sandbox] }
        scopes:
          type: array
          uniqueItems: true
          description: Mutable for eligible non-policyholder sessions; rejected as immutable for policyholder sessions.
          items:
            type: string
            enum: [policies:read, quotes:read, documents:read, agents:read, claims:read, prevention:read]
        expiresAt: { type: [string, "null"], format: date-time }
        contactEmails:
          type: array
          items: { type: string, format: email }
        rateLimitPerMinute:
          type: integer
          minimum: 1
          description: Constrained server-side; policyholder keys always use the fixed policyholder limit.
        rateLimitPerDay:
          type: integer
          minimum: 1
          description: Constrained server-side; policyholder keys always use the fixed policyholder limit.
    SelfServiceApiKeyUpdate:
      type: object
      additionalProperties: false
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        scopes:
          type: array
          uniqueItems: true
          items:
            type: string
            enum: [policies:read, quotes:read, documents:read, agents:read, claims:read, prevention:read]
        expiresAt: { type: [string, "null"], format: date-time }
        contactEmails:
          type: array
          items: { type: string, format: email }
        rateLimitPerMinute:
          type: integer
          minimum: 1
          description: Immutable for policyholder keys.
        rateLimitPerDay:
          type: integer
          minimum: 1
          description: Immutable for policyholder keys.
    Pagination:
      type: object
      properties:
        page: { type: integer }
        limit: { type: integer }
        total: { type: integer }
        totalPages: { type: integer }
    PlaySummary:
      type: object
      required: [key, name, description, partnerVisible]
      properties:
        key: { type: string, example: coi-proof }
        name: { type: string, example: Certificate of Insurance }
        description: { type: string }
        partnerVisible: { type: boolean }
    PlayStep:
      type: object
      required: [key, title, order]
      properties:
        key: { type: string }
        title: { type: string }
        description: { type: string }
        order: { type: integer, minimum: 1 }
    PlayDetail:
      allOf:
        - $ref: "#/components/schemas/PlaySummary"
        - type: object
          required: [ownerRole, operationIds, definitionOfDone, steps]
          properties:
            ownerRole: { type: [string, "null"] }
            operationIds:
              type: array
              items: { type: string }
            definitionOfDone:
              type: array
              items: { type: string }
            steps:
              type: array
              items: { $ref: "#/components/schemas/PlayStep" }
    PlayListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/PlaySummary" }
        pagination: { $ref: "#/components/schemas/Pagination" }
    PlayRun:
      type: object
      required: [id, playKey, serviceTicketId, status, createdAt]
      properties:
        id: { type: string }
        playKey: { type: string }
        serviceTicketId: { type: string }
        status:
          type: string
          enum: [pending, in_progress, completed, failed, cancelled]
        actor: { type: string, enum: [human, agent] }
        notes: { type: [string, "null"], maxLength: 2000 }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        completedAt: { type: [string, "null"], format: date-time }
    TicketPlay:
      type: object
      required: [ticketId, play, runs]
      properties:
        ticketId: { type: string }
        play: { $ref: "#/components/schemas/PlayDetail" }
        runs:
          type: array
          items: { $ref: "#/components/schemas/PlayRun" }
    InternalPlayRunRequest:
      type: object
      additionalProperties: false
      required: [playKey, actor, status]
      properties:
        playKey: { type: string }
        actor: { type: string, enum: [human, agent] }
        status: { type: string, enum: [pending, in_progress, completed, failed, cancelled] }
        notes: { type: string, maxLength: 2000 }
    PartnerPlayStartRequest:
      type: object
      additionalProperties: false
      required: [subject, description]
      properties:
        subject: { type: string, minLength: 1 }
        description: { type: string, minLength: 1 }
        policyholderId: { type: string }
        policyNumber: { type: string }
        contactName: { type: string }
        contactEmail: { type: string, format: email }
    PartnerStartedTicket:
      type: object
      required: [id, ticketType, subject, description, status, agencyId, playKey]
      properties:
        id: { type: string }
        ticketType: { type: string, enum: [coi_request, loss_run] }
        subject: { type: string }
        description: { type: string }
        status: { type: string }
        policyholderId: { type: [string, "null"] }
        policyNumber: { type: [string, "null"] }
        agencyId: { type: string }
        contactName: { type: [string, "null"] }
        contactEmail: { type: [string, "null"], format: email }
        playKey: { type: string, enum: [coi-proof, loss-runs] }
        createdAt: { type: [string, "null"], format: date-time }
        updatedAt: { type: [string, "null"], format: date-time }
    PartnerPlayStartResponse:
      type: object
      required: [data, created, deduplicated]
      properties:
        data: { $ref: "#/components/schemas/PartnerStartedTicket" }
        created: { type: boolean }
        deduplicated: { type: boolean }
    ApiKeyInfo:
      type: object
      properties:
        id: { type: string }
        partnerId: { type: string }
        partnerType: { type: string, example: agency }
        name: { type: string }
        keyPrefix: { type: string }
        environment: { type: string, enum: [live, sandbox] }
        scopes:
          type: array
          items: { type: string }
        rateLimitPerMinute: { type: integer }
        rateLimitPerDay: { type: integer }
        isActive: { type: boolean }
        agencyId: { type: [string, "null"], description: Bound canonical agencies.id for agent keys }
        contactId: { type: [string, "null"], description: Bound canonical contacts.id for agent keys }
        agency:
          type: [object, "null"]
          additionalProperties: false
          properties:
            id: { type: string }
            ptnUuid: { type: [string, "null"] }
            name: { type: [string, "null"] }
        contact:
          type: [object, "null"]
          additionalProperties: false
          properties:
            id: { type: string }
            name: { type: [string, "null"] }
            email: { type: [string, "null"], format: email }
    Job:
      type: object
      properties:
        id: { type: string }
        type: { type: string, example: score.creator-intelligence }
        status:
          type: string
          enum: [queued, running, succeeded, failed]
        environment: { type: string, enum: [live, sandbox] }
        request: {}
        result: {}
        resultUrl: { type: string, nullable: true }
        error: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        startedAt: { type: string, nullable: true, format: date-time }
        completedAt: { type: string, nullable: true, format: date-time }

    # ─── Score ────────────────────────────────────────────────────────
    CreatorScoreRequest:
      type: object
      description: |
        At least one identifier is required from the union
        `email` / `youtubeHandle` / `instagramHandle` / `tiktokHandle`
        / `twitterHandle` / `linkedinUrl` / `websiteUrl`.
      properties:
        email: { type: string, format: email }
        youtubeHandle: { type: string }
        instagramHandle: { type: string }
        tiktokHandle: { type: string }
        twitterHandle: { type: string }
        linkedinUrl: { type: string, format: uri }
        websiteUrl: { type: string }
        physicalAddress: { type: string }
        contactName: { type: string }
        businessName: { type: string }
        webhookUrl: { type: string, format: uri, nullable: true }
    WeatherScoreRequest:
      type: object
      required: [state, zip]
      properties:
        address: { type: string }
        city: { type: string }
        state: { type: string, minLength: 2, maxLength: 2, example: OR }
        zip: { type: string, example: "97202" }
        latitude: { type: number, minimum: -90, maximum: 90 }
        longitude: { type: number, minimum: -180, maximum: 180 }
    CrimeScoreRequest:
      type: object
      required: [address, city, state, zip]
      properties:
        address: { type: string }
        city: { type: string }
        state: { type: string, minLength: 2, maxLength: 2 }
        zip: { type: string }
        companyName: { type: string }
    AutoFatalityScoreRequest:
      type: object
      description: |
        Provide either address mode (`address`+`city`+`state`+`zip`)
        OR coordinates mode (`lat`+`lng`+`radius_meters`), not both.
      properties:
        address: { type: string }
        city: { type: string }
        state: { type: string, minLength: 2, maxLength: 2 }
        zip: { type: string }
        lat: { type: number, minimum: -90, maximum: 90 }
        lng: { type: number, minimum: -180, maximum: 180 }
        radius_meters: { type: number, exclusiveMinimum: 0, maximum: 50000 }
        companyName: { type: string }
    ScoreResultGeneric:
      type: object
      description: Score + band + risk-specific factors. Shape varies per scorer.
      additionalProperties: true

    # ─── Underwriting ─────────────────────────────────────────────────
    UnderwritingRequest:
      type: object
      description: |
        Either `fixture_key` (sandbox only — references one of the 15
        golden fixtures in `server/platformApi/underwriting/fixtures.ts`)
        OR `application` is required.
      properties:
        fixture_key: { type: string, example: clean_accept }
        application:
          type: object
          required: [company_name, address, requested_lines]
          properties:
            company_name: { type: string }
            address:
              type: object
              required: [street, city, state, zip]
              properties:
                street: { type: string }
                city: { type: string }
                state:
                  type: string
                  minLength: 2
                  maxLength: 2
                  description: |
                    2-letter region code. For a domestic application
                    (country omitted or a US token) this MUST be a valid
                    U.S. state code. For an explicitly non-US `country`,
                    any 2-letter region is accepted (e.g. `ON`) and the
                    jurisdiction gate returns `unsupported_country`.
                zip: { type: string }
                country:
                  type: string
                  minLength: 2
                  maxLength: 56
                  description: |
                    Optional. Omitted or a US token (`US`, `USA`,
                    `United States`) = domestic. A non-US value (e.g.
                    `Canada`) routes the evaluation to the jurisdiction
                    gate → `unsupported_country` decline.
            industry_class: { type: string }
            annual_revenue: { type: number, minimum: 0 }
            revenue_self_reported: { type: number, minimum: 0 }
            num_losses_in_prev_five_years: { type: integer, minimum: 0, maximum: 99 }
            largest_prior_loss_usd: { type: number, minimum: 0 }
            requested_lines:
              type: array
              minItems: 1
              items: { type: string, enum: [mpl, property, gl, cyber] }
            coverage_limits:
              type: object
              additionalProperties: { type: number }
            cyber_questionnaire:
              type: object
              additionalProperties: { type: string }
        scores:
          type: object
          additionalProperties: true
        evaluator_notes: { type: string, maxLength: 2000 }
    UnderwritingEvaluation:
      type: object
      description: |
        Public shape returned by `POST /underwriting/evaluate`,
        `GET /underwriting/evaluations/{id}`, and the
        additional-info endpoint. All field names are snake_case.
      properties:
        id: { type: string, format: uuid }
        state:
          type: string
          enum: [draft, submitted, in_review, approved, declined]
        decision:
          type: string
          enum: [accept, refer, decline]
        environment: { type: string, enum: [live, sandbox] }
        fixture_key:
          type: string
          nullable: true
          description: Set when the evaluation was triggered via a sandbox `fixture_key`.
        decision_payload:
          type: object
          description: |
            Full evaluator output, including the per-line eligibility
            grid, IRPM modifiers, missing-information items, and any
            evaluator notes. Shape is determined by `EvaluationResult`.
          additionalProperties: true
        missing_information:
          type: array
          description: Items the underwriter still needs from the partner.
          items:
            type: object
            additionalProperties: true
        irpm_override: { type: number, nullable: true }
        irpm_override_reason: { type: string, nullable: true }
        reviewed_by: { type: string, nullable: true }
        reviewed_at: { type: string, nullable: true, format: date-time }
        review_notes: { type: string, nullable: true }
        status_history:
          type: array
          items:
            type: object
            additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    # ─── Rate ─────────────────────────────────────────────────────────
    BaseRateRequest:
      type: object
      required: [evaluation_id]
      properties:
        evaluation_id: { type: string, format: uuid }
        idempotency_key: { type: string, maxLength: 255 }
    MplRateRequest:
      allOf:
        - $ref: "#/components/schemas/BaseRateRequest"
        - type: object
          required:
            [
              total_revenue,
              occurrence_limit,
              aggregate_limit,
              deductible_amount,
              experience_type,
              claims_history,
              business_type,
            ]
          properties:
            total_revenue: { type: number, exclusiveMinimum: 0 }
            occurrence_limit: { type: integer, minimum: 1 }
            aggregate_limit: { type: integer, minimum: 1 }
            deductible_amount: { type: integer, minimum: 0 }
            experience_type: { type: string }
            claims_history: { type: string }
            business_type: { type: string, enum: [new, renewal] }
            optional_coverages:
              type: array
              items: { type: string }
            retroactive_period_months: { type: integer, minimum: 0 }
            renewal_number: { type: integer, minimum: 0 }
            extended_reporting_months: { type: integer, minimum: 0 }
            revenue_allocations:
              type: array
              items:
                type: object
                required: [serviceClassId, classNumber, revenuePercentage]
                properties:
                  serviceClassId: { type: string }
                  serviceClassTitle: { type: string }
                  classNumber: { type: integer }
                  revenuePercentage: { type: number, minimum: 0, maximum: 100 }
            services:
              type: array
              minItems: 1
              items: { type: string }
            business_description: { type: string }
            industry: { type: string }
    PropertyRateRequest:
      allOf:
        - $ref: "#/components/schemas/BaseRateRequest"
        - type: object
          properties:
            equipment_value: { type: number, minimum: 0, default: 0 }
            all_risk_limit: { type: number, minimum: 0, default: 0 }
            business_income_monthly: { type: number, minimum: 0, default: 0 }
            include_creator_continuity: { type: boolean, default: false }
            state: { type: string, minLength: 2, maxLength: 2 }
    GlRateRequest:
      allOf:
        - $ref: "#/components/schemas/BaseRateRequest"
        - type: object
          required: [total_revenue, tier, occurrence_limit, aggregate_limit]
          properties:
            total_revenue: { type: number, exclusiveMinimum: 0 }
            tier: { type: string, enum: [carveout, full] }
            occurrence_limit: { type: integer, minimum: 1 }
            aggregate_limit: { type: integer, minimum: 1 }
            deductible: { type: integer, minimum: 0, default: 0 }
            hazard_class: { type: integer, minimum: 1, maximum: 5, default: 2 }
            endorsements:
              type: object
              properties:
                additional_insured: { type: boolean, default: false }
                waiver_of_subrogation: { type: boolean, default: false }
                affirmative_ai: { type: boolean, default: false }
    CyberRateRequest:
      allOf:
        - $ref: "#/components/schemas/BaseRateRequest"
        - type: object
          required: [iso_gl_code, state, aggregate_limit]
          properties:
            iso_gl_code: { type: string }
            state: { type: string, minLength: 2, maxLength: 2 }
            aggregate_limit: { type: integer, minimum: 1 }
            revenue: { type: number, minimum: 0, default: 0 }
            ny_claims_made_year:
              { type: integer, minimum: 1, maximum: 10, nullable: true }
            questionnaire_answers:
              type: object
              additionalProperties: { type: string }
              nullable: true
    TrimpayRateRequest:
      allOf:
        - $ref: "#/components/schemas/BaseRateRequest"
        - type: object
          required: [business_type, adr_tier, devices]
          properties:
            business_type: { type: string, enum: [commercial, residential] }
            adr_tier:
              { type: string, enum: [adr1000, adr2000, adr3000] }
            devices:
              type: array
              minItems: 1
              items:
                type: object
                required: [device_id, unit_cost, quantity]
                properties:
                  device_id: { type: string }
                  name: { type: string }
                  risk_category: { type: string }
                  unit_cost: { type: number, minimum: 0 }
                  quantity: { type: integer, minimum: 1 }
    RateLineResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            line_id: { type: string, format: uuid }
            line: { type: string }
            evaluation_id: { type: string, format: uuid }
            premium_annual: { type: number }
            premium_monthly: { type: number }
            modifiers:
              type: object
              additionalProperties: true

    # ─── External quote pipeline imports ──────────────────────────────
    ExternalQuoteVisibilityInput:
      type: object
      additionalProperties: false
      properties:
        staff: { type: boolean, const: true, default: true }
        agent: { type: boolean, const: true, default: true }
        policyholder: { type: boolean, const: false, default: false }
    ExternalQuoteOwnershipInput:
      type: object
      additionalProperties: false
      required: [agencyId, contactId]
      description: |
        Canonical ownership. `agencyId` accepts `agencies.id` or its persisted
        `agencies.ptnUuid` input namespace and is returned as `agencies.id`.
        `contactId` is `contacts.id`, not a carrier producer code or display name.
      properties:
        agencyId: { type: string, minLength: 1, maxLength: 255, description: "agencies.id or agencies.ptnUuid; response canonicalizes to agencies.id." }
        contactId: { type: string, minLength: 1, maxLength: 255, description: "Canonical contacts.id." }
    ExternalQuoteOwnership:
      type: object
      additionalProperties: false
      required: [agencyId, contactId]
      description: Canonical persisted ownership returned with the quote record; agencyId is agencies.id and contactId is contacts.id.
      properties:
        agencyId: { type: [string, "null"] }
        contactId: { type: [string, "null"] }
    ExternalQuoteOwnershipFrom:
      type: object
      additionalProperties: false
      required: [carrier]
      description: Existing portal ownership-resolution input; it is resolved only against persisted authority and never creates an owner.
      properties:
        carrier: { type: string, minLength: 1, maxLength: 120 }
        producer_or_agency_code: { type: [string, "null"], maxLength: 120 }
        submitting_agent_name: { type: [string, "null"], maxLength: 255 }
        fallback_agency_id: { type: [string, "null"], maxLength: 255 }
        fallback_contact_id: { type: [string, "null"], maxLength: 255 }
    ExternalQuoteTransactionType:
      type: string
      enum: [new_business, renewal, endorsement, other]
      description: Travelers imports must provide this discriminator. Travelers `new_business` is eligible by default; retained renewal and endorsement evidence requires an explicit filter.
    ExternalQuoteImportItem:
      type: object
      additionalProperties: false
      required: [source, carrier_id, external_quote_id, status]
      description: |
        One carrier pipeline row. Ownership is required for an agency-scoped
        key; it may be omitted only when a valid agent-bound key supplies the
        same canonical ownership. `ownership` and `ownershipFrom` are mutually
        exclusive. Camel-case compatibility aliases may be accepted by the
        runtime only when they match the documented snake-case fields.
      properties:
        source:
          type: string
          enum: [hartford_icon, travelers_policy_activity, external_carrier]
          minLength: 1
          maxLength: 100
          example: hartford_icon
        carrier_id: { type: string, minLength: 1 }
        external_quote_id: { type: string, minLength: 1, maxLength: 255 }
        contact_id:
          type: string
          minLength: 1
          description: Optional canonical contacts.id. It must agree with `ownership.contactId` when both are supplied.
        policyholder_id:
          type: string
          minLength: 1
          description: Optional existing, in-book policyholder id; no policyholder is created or inferred by name.
        ownership: { $ref: "#/components/schemas/ExternalQuoteOwnershipInput" }
        ownershipFrom: { $ref: "#/components/schemas/ExternalQuoteOwnershipFrom" }
        named_insured: { type: [string, "null"], maxLength: 500 }
        status:
          type: string
          minLength: 1
          maxLength: 255
          description: Exact carrier portal status; retained verbatim as rawStatus.
        transaction_type:
          $ref: "#/components/schemas/ExternalQuoteTransactionType"
          description: Optional for existing clients. Hartford defaults an omitted value to `new_business`; Travelers requires an explicit value.
        product_lob: { type: [string, "null"], maxLength: 120 }
        effective_date: { type: [string, "null"], format: date }
        premium: { type: [string, number, "null"] }
        quote_date: { type: [string, "null"], format: date }
        producer_or_agency_code: { type: [string, "null"], maxLength: 120 }
        last_activity: { type: [string, "null"], maxLength: 64 }
        submitted_at:
          type: [string, "null"]
          maxLength: 64
          description: Optional portal date/time evidence. Date-only and offset-less values are America/Chicago; offset-bearing ISO-8601 values retain their instant. Otherwise use portal activity, then first import time.
        csr: { type: [string, "null"], maxLength: 255 }
        metadata:
          type: [object, "null"]
          additionalProperties: true
        visibility:
          $ref: "#/components/schemas/ExternalQuoteVisibilityInput"
      allOf:
        - not:
            required: [ownership, ownershipFrom]
        - if:
            properties:
              source:
                pattern: '.*[Tt][Rr][Aa][Vv][Ee][Ll][Ee][Rr][Ss].*'
            required: [source]
          then:
            required: [transaction_type]
    ExternalQuoteImportBatch:
      type: object
      additionalProperties: false
      required: [quotes]
      properties:
        quotes:
          type: array
          minItems: 1
          maxItems: 200
          items: { $ref: "#/components/schemas/ExternalQuoteImportItem" }
    ExternalQuoteImportInput:
      oneOf:
        - $ref: "#/components/schemas/ExternalQuoteImportItem"
        - $ref: "#/components/schemas/ExternalQuoteImportBatch"
    ExternalQuoteRecord:
      type: object
      required:
        - id
        - policyholderId
        - agentId
        - agencyId
        - ownership
        - source
        - externalQuoteId
        - carrier
        - contact
        - namedInsured
        - normalizedStatus
        - rawStatus
        - premium
        - effectiveDate
        - coverageType
        - date
        - productLob
        - submittedAt
        - transactionType
        - metadata
        - visibility
        - provenance
      properties:
        id: { type: string }
        policyholderId: { type: [string, "null"] }
        agentId: { type: [string, "null"] }
        agencyId: { type: [string, "null"] }
        ownership:
          oneOf:
            - $ref: "#/components/schemas/ExternalQuoteOwnership"
            - type: "null"
        vendorId: { type: [string, "null"] }
        source: { type: [string, "null"] }
        externalQuoteId: { type: [string, "null"] }
        carrier:
          type: [object, "null"]
          properties:
            id: { type: string }
            name: { type: string }
        contact:
          type: [object, "null"]
          properties:
            id: { type: string }
            conId: { type: [string, "null"] }
            fullName: { type: [string, "null"] }
            email: { type: [string, "null"] }
        namedInsured: { type: [string, "null"] }
        status:
          type: [string, "null"]
          description: Normalized business status for external records.
        normalizedStatus:
          type: [string, "null"]
          enum:
            - submitted
            - in_progress
            - needs_information
            - rated
            - approved
            - approved_with_conditions
            - declined
            - expired
            - issued_external
            - closed
            - other
            - null
        rawStatus:
          type: [string, "null"]
          description: Exact carrier portal value, retained for display and audit.
        syncStatus:
          type: [string, "null"]
          description: FileMaker synchronization state; separate from external business status.
        premium: { type: [string, "null"] }
        effectiveDate: { type: [string, "null"] }
        coverageType: { type: [string, "null"] }
        date: { type: [string, "null"] }
        productLob: { type: [string, "null"] }
        producerOrAgencyCode: { type: [string, "null"] }
        lastActivity: { type: [string, "null"] }
        submittedAt: { type: [string, "null"], format: date-time }
        transactionType:
          oneOf:
            - $ref: "#/components/schemas/ExternalQuoteTransactionType"
            - type: "null"
        csr: { type: [string, "null"] }
        metadata:
          type: [object, "null"]
          additionalProperties: true
        visibility:
          type: object
          required: [staff, agent, policyholder]
          properties:
            staff: { type: boolean, const: true }
            agent: { type: boolean, const: true }
            policyholder: { type: boolean, const: false }
        provenance:
          type: [object, "null"]
          properties:
            environment: { type: string, enum: [sandbox, live] }
            importedAt: { type: [string, "null"], format: date-time }
            updatedAt: { type: [string, "null"], format: date-time }
    ExternalQuoteListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/ExternalQuoteRecord" }
        pagination:
          type: object
          required: [page, limit, total, totalPages]
          description: total is the filtered count, including quote-date and productLob filters.
          properties:
            page: { type: integer }
            limit: { type: integer }
            total: { type: integer }
            totalPages: { type: integer }
        countOnly:
          type: boolean
          description: True when the caller asked for a count without a page of rows.
        total:
          type: integer
          description: Same filtered count as pagination.total. Present when countOnly is true.
    ExternalQuoteImportResult:
      type: object
      required: [index, ok, outcome]
      properties:
        index: { type: integer, minimum: 0 }
        ok: { type: boolean }
        outcome: { type: string, enum: [created, updated, error] }
        id:
          type: string
          description: Persisted quote id; present for successful created or updated rows.
        data: { $ref: "#/components/schemas/ExternalQuoteRecord" }
        error: { $ref: "#/components/schemas/ExternalQuoteImportError" }
    ExternalQuoteImportResponse:
      oneOf:
        - type: object
          required: [outcome, created, updated, data]
          properties:
            outcome: { type: string, enum: [created, updated] }
            created: { type: boolean }
            updated: { type: boolean }
            data: { $ref: "#/components/schemas/ExternalQuoteRecord" }
        - type: object
          required: [data]
          properties:
            data:
              type: object
              required: [results]
              properties:
                results:
                  type: array
                  items: { $ref: "#/components/schemas/ExternalQuoteImportResult" }
    ExternalQuoteImportCreatedResponse:
      type: object
      required: [outcome, created, updated, data]
      properties:
        outcome: { type: string, const: created }
        created: { type: boolean, const: true }
        updated: { type: boolean, const: false }
        data: { $ref: "#/components/schemas/ExternalQuoteRecord" }
    ExternalQuoteImportErrorFieldErrors:
      type: array
      minItems: 1
      description: Per-field messages retain the nested batch path, e.g. `quotes.3.ownership.contactId`.
      items:
        type: object
        additionalProperties: false
        required: [path, message]
        properties:
          path: { type: string, example: "quotes.3.ownership.contactId" }
          message: { type: string, example: "contactId is required" }
    ExternalQuoteImportError:
      type: object
      additionalProperties: false
      description: |
        Standard non-validation import error. Middleware/idempotency failures
        may omit retryable and details; validation responses use
        ExternalQuoteImportValidationError instead.
      required: [code, message]
      properties:
        code:
          type: string
          enum: [VALIDATION_ERROR, POLICYHOLDER_REQUIRED, IDEMPOTENCY_KEY_REQUIRED, CARRIER_NOT_FOUND, CONTACT_NOT_FOUND, CONTACT_OWNERSHIP_UNRESOLVED, CONTACT_OUTSIDE_BOOK, POLICYHOLDER_NOT_FOUND, POLICYHOLDER_AMBIGUOUS, POLICYHOLDER_OUTSIDE_BOOK, OWNERSHIP_UNRESOLVED, OWNERSHIP_CONFLICT, EXTERNAL_QUOTE_OWNERSHIP_CONFLICT, IDEMPOTENCY_CONFLICT, IDEMPOTENCY_IN_PROGRESS, IDEMPOTENCY_RETRY_REQUIRED, IDEMPOTENCY_UNAVAILABLE, API_KEY_CHANGED, FORBIDDEN, INTERNAL_ERROR]
        message: { type: string }
        retryable:
          type: boolean
          description: Present when the emitting layer classifies retryability.
        details:
          $ref: "#/components/schemas/ExternalQuoteImportErrorDetails"
    ExternalQuoteImportErrorDetails:
      type: object
      additionalProperties: true
      properties:
        fieldErrors: { $ref: "#/components/schemas/ExternalQuoteImportErrorFieldErrors" }
    ExternalQuoteImportValidationErrorDetails:
      type: object
      additionalProperties: false
      required: [fieldErrors]
      properties:
        fieldErrors: { $ref: "#/components/schemas/ExternalQuoteImportErrorFieldErrors" }
    ExternalQuoteImportValidationError:
      type: object
      additionalProperties: false
      required: [code, message, retryable, details]
      properties:
        code:
          type: string
          enum: [VALIDATION_ERROR, POLICYHOLDER_REQUIRED]
        message: { type: string }
        retryable: { type: boolean, const: false }
        details: { $ref: "#/components/schemas/ExternalQuoteImportValidationErrorDetails" }
    ExternalQuoteImportErrorResponse:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/ExternalQuoteImportError" }
    ExternalQuoteImportValidationErrorResponse:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/ExternalQuoteImportValidationError" }

    # ─── Evaluated/rated StudioGuard quote assembly ───────────────────
    QuoteAssembleRequest:
      type: object
      required: [evaluation_id, line_ids, policyholder_state]
      properties:
        evaluation_id: { type: string, format: uuid }
        line_ids:
          type: array
          minItems: 1
          items: { type: string, format: uuid }
        policyholder_state: { type: string, minLength: 2, maxLength: 2 }
        policyholder_name: { type: string }
        effective_date: { type: string, format: date }
    Quote:
      type: object
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        hosted_url: { type: string, format: uri }
        evaluation_id: { type: string, format: uuid }
        line_ids:
          type: array
          items: { type: string, format: uuid }
        policyholder_name: { type: string, nullable: true }
        policyholder_state: { type: string }
        totals:
          type: object
          properties:
            gross_premium: { type: number }
            total_taxes: { type: number }
            total_with_taxes: { type: number }
            monthly_amount: { type: number }
            line_count: { type: integer }
            eligible_line_count: { type: integer }
            per_line:
              type: array
              items:
                type: object
                properties:
                  line: { type: string }
                  line_id: { type: string }
                  premium_annual: { type: number }
                  premium_monthly: { type: number }
                  eligible: { type: boolean }
        taxes:
          type: object
          additionalProperties: true
        monthly_schedule:
          type: array
          items:
            type: object
            properties:
              due_date: { type: string, format: date }
              amount: { type: number }
        created_at: { type: string, format: date-time }

    # ─── Bind ─────────────────────────────────────────────────────────
    BindRequest:
      type: object
      required: [quote_id, insured]
      properties:
        quote_id: { type: string, format: uuid }
        insured:
          type: object
          required: [name]
          properties:
            name: { type: string }
            type: { type: string }
            email: { type: string, format: email }
            address1: { type: string }
            address2: { type: string }
            city: { type: string }
            state: { type: string, minLength: 2, maxLength: 2 }
            zip: { type: string }
            website: { type: string }
            trade_occupation: { type: string }
        effective_date: { type: string, format: date }
        expiration_date: { type: string, format: date }
        retro_date: { type: string, format: date }
        payment_plan:
          { type: string, enum: [monthly, annual], default: monthly }
        agent_id: { type: string, nullable: true }
    BoundPolicy:
      type: object
      properties:
        policy_id: { type: string, format: uuid }
        policy_number: { type: string }
        quote_id: { type: string, format: uuid }
        commission_split:
          type: object
          properties:
            agent_commission_pct: { type: number }
            agent_commission_amt: { type: number }
            mga_net_retention_amt: { type: number }
            pending_ledger_id: { type: string }
        coverage_documents:
          type: array
          items: { type: string, format: uri }
        status: { type: string, example: in_force_pending_payment }
        created_at: { type: string, format: date-time }
    PaymentIntent:
      type: object
      properties:
        client_secret: { type: string }
        amount: { type: number }
        currency: { type: string, example: usd }
        payment_intent_id: { type: string }

    # ─── BRDX ─────────────────────────────────────────────────────────
    BrdxWrittenRow:
      type: object
      properties:
        report_id: { type: string }
        period: { type: string }
        policy_number: { type: string }
        carrier: { type: string }
        line_of_business: { type: string }
        bound_at: { type: string, format: date-time }
        gross_premium: { type: number }
    BrdxPaidRow:
      type: object
      properties:
        report_id: { type: string }
        period: { type: string }
        policy_number: { type: string }
        amount: { type: number }
        paid_at: { type: string, format: date-time }
        stripe_payment_id: { type: string }

    # ─── Commission ───────────────────────────────────────────────────
    CommissionLedgerRow:
      type: object
      description: |
        Public shape of a `commission_ledger` row, returned by
        `GET /commissions/ledger` and embedded as `data` in the
        `commission.accrued` / `commission.payable` webhooks.
      properties:
        id: { type: string, description: Ledger row UUID. }
        source:
          type: string
          description: Origin system (e.g., `studioguard`, `imports`).
        carrier: { type: string }
        program: { type: string }
        policy_number: { type: string }
        insured_name: { type: string, nullable: true }
        agent_id: { type: string }
        effective_date: { type: string, format: date-time, nullable: true }
        expiration_date: { type: string, format: date-time, nullable: true }
        transaction_type: { type: string, example: "new_business" }
        premium: { type: number }
        status:
          type: string
          enum: [pending, verified, payable, paid, held, reversed, clawed_back]
        hold_reason: { type: string, nullable: true }
        payable_after:
          type: string
          format: date-time
          nullable: true
          description: When the row will (or did) age to `payable`. ~10-day default hold.
        paid_at: { type: string, format: date-time, nullable: true }
        payment_id:
          type: string
          nullable: true
          description: FK to `commission_payments.id` once the row is included in a withdrawal.
        is_reversal: { type: boolean }
        reversal_of_ledger_id: { type: string, nullable: true }
        installment_number: { type: integer }
        created_at: { type: string, format: date-time, nullable: true }
        updated_at: { type: string, format: date-time, nullable: true }
        split_rationale:
          type: object
          description: Full audit trail of how the split was computed.
          properties:
            carrier_commission_pct: { type: number }
            carrier_commission_amt: { type: number }
            mga_expense_pct: { type: number }
            mga_expense_amt: { type: number }
            agent_base_commission_pct: { type: number }
            agent_tier: { type: string, nullable: true }
            agent_tier_split_pct: { type: number }
            agent_commission_pct: { type: number }
            agent_commission_amt: { type: number }
            mga_net_retention_amt: { type: number }
    CommissionWallet:
      type: object
      description: |
        Returned by `GET /commissions/wallet/{agent_id}`. All dollar
        fields are USD doubles. `holds.*` reflect the live Lexi
        compliance gate evaluated at request time.
      properties:
        agent_id: { type: string }
        earned: { type: number, description: Lifetime earned (pending + payable + paid). }
        pending: { type: number }
        pending_count: { type: integer }
        payable: { type: number }
        paid: { type: number }
        paid_ytd: { type: number }
        next_aging_date:
          type: string
          format: date-time
          nullable: true
          description: Earliest `payable_after` among rows still inside their hold window.
        tier: { type: string, example: "gold" }
        tier_split_pct: { type: number }
        gross_program_commission_pct: { type: number, example: 18 }
        direct_deposit_active: { type: boolean }
        holds:
          type: object
          properties:
            license_expired: { type: boolean }
            eo_expired: { type: boolean }
            no_bank_account: { type: boolean }
            payment_hold: { type: boolean }
        licensing:
          type: object
          properties:
            licensed_states:
              type: array
              items: { type: string }
        compliance:
          type: object
          properties:
            pass: { type: boolean }
            hold_reason: { type: string, nullable: true }
            can_withdraw: { type: boolean }
            bank_account_last4: { type: string, nullable: true }
    WithdrawalRequest:
      type: object
      required: [agent_id]
      properties:
        agent_id: { type: string }
        amount:
          description: |
            Optional partial-withdrawal amount in USD. Omit (or pass
            the literal string `"max payable"`) to withdraw the
            entire payable balance. Whole-row granularity — the
            oldest payable rows are selected until the requested
            amount is covered.
          oneOf:
            - { type: number, exclusiveMinimum: 0, maximum: 10000000 }
            - { type: string, enum: ["max payable"] }
        memo: { type: string, maxLength: 200 }
    Withdrawal:
      type: object
      description: |
        Successful withdrawal. Wrapped in `{ data, meta }` on the HTTP
        response and re-emitted as `commission.paid`.
      properties:
        withdrawal_id: { type: string, description: FK to `commission_payments.id`. }
        agent_id: { type: string }
        amount: { type: number }
        ach_eta:
          type: string
          format: date-time
          description: Estimated ACH settlement timestamp.
        bank_account_last4: { type: string, nullable: true }
        included_ledger_ids:
          type: array
          items: { type: string }
        status: { type: string, example: processing }
        initiated_at: { type: string, format: date-time }

    # ─── Webhooks ─────────────────────────────────────────────────────
    WebhookDeliveryHeaders:
      type: object
      description: |
        Optional static string-to-string headers merged into every live,
        retry, test, and post-reactivation delivery. `Authorization` is
        allowed. Transport/hop-by-hop names (for example `Host`, `Connection`,
        `Content-Length`, `Transfer-Encoding`, `Keep-Alive`, `TE`, `Trailer`,
        and `Upgrade`), `Content-Type`, `X-Pulse-*`, `Proxy-*`, `Sec-*`,
        `X-Forwarded-*`, and cookie/origin/referrer-style names are rejected.
        Custom headers never replace or alter Pulse's `X-Pulse-Signature`,
        `X-Pulse-Event`, `X-Pulse-Delivery`, or `X-Pulse-Timestamp` headers.
        If a receiver echoes a configured value, Pulse redacts that value from
        stored response-body logs.
      additionalProperties:
        type: string
      example:
        Authorization: "Bearer <BOT_WAKEUP_TOKEN>"

    WebhookSubscription:
      type: object
      description: |
        Public shape of a `webhook_subscriptions` row. The plaintext
        `secret` is returned **only on the create response and on
        `POST /webhooks/{id}/rotate-secret`** so the partner can
        record it; every other read omits it. Static delivery `headers`
        are likewise write-only: values are returned only by
        `POST /webhooks` and by a `PATCH /webhooks/{id}` that includes
        `headers`; GET/list responses omit them. After 10 consecutive
        failed deliveries the subscription is auto-paused
        (`isActive=false`); reactivate it with
        `PATCH /webhooks/{id}` (body `{ "isActive": true }`), which
        flips it back on, resets `failureCount` to `0`, and preserves
        the original signing `secret` so the partner does not need to
        re-record it. To rotate the signing secret without disturbing
        the rest of the subscription (e.g. after a suspected leak),
        use `POST /webhooks/{id}/rotate-secret`.
      properties:
        id: { type: string }
        url:
          type: string
          format: uri
          description: Public HTTPS endpoint; internal/reserved targets and redirects are not allowed.
        events:
          type: array
          items:
            type: string
            description: Supported event name, including `chat_escalation.created`.
            example: chat_escalation.created
        isActive: { type: boolean }
        failureCount: { type: integer, description: Consecutive failed deliveries since the last success. }
        lastDeliveredAt: { type: string, format: date-time, nullable: true }
        lastFailedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        secret:
          type: string
          description: One-time plaintext secret. Returned ONLY by `POST /webhooks` on creation and by `POST /webhooks/{id}/rotate-secret`; never returned by reads.
        headers:
          allOf:
            - $ref: "#/components/schemas/WebhookDeliveryHeaders"
          description: Present only in the create response or a PATCH response when that request supplied `headers`; omitted from GET/list responses.