openapi: 3.1.0
info:
  title: Mildport API
  version: '1.0.0'
  description: >
    Public transport for the embeddable Mildport. Every consumer — external
    CRM hosts AND Capitality (tenant #1) — calls these routes; there is no
    privileged internal path. Auth is a per-tenant signed license key; the
    tenant is derived from the key. All responses use the clean envelope
    `{ status:'ok', payload }` or `{ status:'error', code, issues? }`; the legacy wire encodings (qlt/hA/base64, Handsontable key) have been removed.
    Wire shapes mirror the published `@capitality-io/mildport-contract`.

    Ingest + records + mapping-session + apply/webhooks are implemented (sync
    ingest ≤10 MB; larger files return async job + status poll). On `submit`
    the service delivers a signed, replay-safe webhook to host-registered
    endpoints; the browser `onResults` path is client-side only.
servers:
  - url: /api
    description: Nest global prefix

security:
  - licenseKey: []

paths:
  /import/v1/license/verify:
    get:
      summary: Verify a license key and return entitlements
      operationId: verifyLicense
      responses:
        '200':
          description: Verification result (verified:false for a present-but-invalid key)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/LicenseVerifyPayload' }
                required: [status, payload]
        '401':
          $ref: '#/components/responses/Error'

  /import/v1/mapping/column:
    post:
      summary: Smart-match source headers to the host's target columns
      operationId: mapColumns
      description: Requires the `serverMapping` entitlement.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ColumnMappingRequest' }
      responses:
        '200':
          description: >
            One suggestion list per input header PLUS the engine-decided
            assignment. `mapping` is the finished decision after every layer
            (deterministic match, AI arbitration when live, calibrated
            tie-break) — consumers should apply it rather than re-derive
            assignments from `payload`; `""` marks a deliberate abstention.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/ColumnMappingRow' }
                  mapping:
                    type: object
                    additionalProperties: { type: string }
                    description: Header → assigned column key ("" = abstained).
                  meta:
                    type: object
                    properties:
                      threshold:
                        type: number
                        description: The auto-assign confidence bar the mapping was decided at.
                    required: [threshold]
                required: [status, payload, mapping, meta]
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/mapping/auto:
    post:
      summary: Persist an auto-mapping layout for a template identifier
      operationId: saveAutoMapping
      description: Requires the `autoMapping` entitlement. Idempotent on (tenant, identifier, fingerprint).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                identifier: { type: string }
                mapping:
                  description: The host's saved column-mapping layout (opaque to the service).
                groundedHeaders:
                  type: array
                  maxItems: 500
                  items: { type: string }
                  description: >
                    Source headers a human actively mapped/confirmed — alias
                    learning from this save is grounded on these pairs only
                    (absent → the template is saved but nothing is learned).
              required: [identifier, mapping]
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/mapping/auto/lookup:
    post:
      summary: Look up a saved auto-mapping by identifier (optionally exact by fingerprint)
      operationId: lookupAutoMapping
      description: >
        Requires the `autoMapping` entitlement. With a `fingerprint` it is an
        exact lookup; without one it returns the latest mapping for the
        identifier. `payload` is null on a miss.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                identifier: { type: string }
                fingerprint: { type: string }
              required: [identifier]
      responses:
        '200':
          description: The stored mapping, or null on a miss
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    oneOf:
                      - { $ref: '#/components/schemas/AutoMapping' }
                      - { type: 'null' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/mapping/learn:
    post:
      summary: Record learned header→column aliases from an accepted mapping
      operationId: learnFromMapping
      description: >
        Requires the `browserMapping` entitlement (all plans). Persists per-tenant
        learned aliases for reuse at match time — does not save a fingerprint
        template. Used by the widget's browser apply path (`onResults`); webhook
        apply learns server-side on `POST /records/{recordId}/apply` instead.
        Best-effort: failures are swallowed server-side.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mapping:
                  description: Accepted column mapping (`header → target column key`).
                groundedHeaders:
                  type: array
                  maxItems: 500
                  items: { type: string }
                  description: >
                    Source headers a HUMAN actively mapped or confirmed (per-field
                    picks, never bulk auto-map). Learning is grounded on these
                    pairs only; when absent, NOTHING is learned — silent
                    auto-assignments must not teach the engine its own guesses.
              required: [mapping]
      responses:
        '200':
          description: Recorded (or no-op when mapping is empty or ungrounded)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '400':
          $ref: '#/components/responses/Error'
        '401':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/mapping/aliases:
    get:
      summary: List everything the engine has learned from this tenant's imports
      operationId: listLearnedAliases
      description: >
        Requires the `browserMapping` entitlement. The learning audit surface:
        each entry is one learned header→column alias with its confirmation
        count and recency. Pairs with `DELETE` so a tenant can inspect — and
        reset — its own matching flywheel. A hosted page over these endpoints
        ships at `/admin/aliases` (outside the versioned API).
      responses:
        '200':
          description: Learned aliases, newest-confirmed first
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/LearnedAlias' }
                required: [status, payload]
        '401':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
    delete:
      summary: Forget learned aliases — all, or scoped to a column/header
      operationId: purgeLearnedAliases
      description: >
        Requires the `browserMapping` entitlement. Without query parameters it
        forgets everything the engine learned from this tenant; `columnKey`
        and/or `header` scope the purge. Safe: the matcher falls back to its
        built-in rules.
      parameters:
        - name: columnKey
          in: query
          required: false
          schema: { type: string }
          description: Only forget aliases learned for this target column key.
        - name: header
          in: query
          required: false
          schema: { type: string }
          description: Only forget this learned source header (case-insensitive).
      responses:
        '200':
          description: Purged
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  deleted: { type: integer }
                required: [status, deleted]
        '401':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/telemetry/complete-import:
    post:
      summary: Record import-completion telemetry
      operationId: telemetryCompleteImport
      requestBody: { $ref: '#/components/requestBodies/Telemetry' }
      responses:
        '200':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }

  /import/v1/telemetry/cleaning-assistant/logs:
    post:
      summary: Record cleaning-assistant telemetry
      operationId: telemetryCleaningLogs
      requestBody: { $ref: '#/components/requestBodies/Telemetry' }
      responses:
        '200':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }

  /import/v1/telemetry/event-log:
    post:
      summary: Record an event-log entry
      operationId: telemetryEventLog
      requestBody: { $ref: '#/components/requestBodies/Telemetry' }
      responses:
        '200':
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }

  /import/v1/ingest/{jobId}/status:
    get:
      summary: Async ingest job status
      operationId: ingestJobStatus
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Job status
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/IngestJobStatus' }
                required: [status, payload]
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/ingest/file:
    post:
      summary: File ingest (sync ≤10 MB; async for larger files up to license cap)
      operationId: ingestFile
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                bytesBase64: { type: string }
                mime: { type: [string, 'null'] }
                externalId:
                  type: string
                  maxLength: 256
                  description: >
                    Optional caller correlation handle (e.g. your project/workspace id).
                    Echoed back on GET /records/{recordId} and folded into dedup, so the
                    same bytes under a different handle stay distinct. The service does not
                    interpret it.
              required: [bytesBase64, mime]
      responses:
        '200':
          description: Normalized record created or deduplicated
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/IngestFileResult' }
                required: [status, payload]
        '413':
          $ref: '#/components/responses/Error'
        '429':
          description: >-
            MONTHLY_POOL_EXCEEDED — a hard-pool license has already spent a
            monthly pool; starting new imports resumes next UTC period.

  /import/v1/ingest/text:
    post:
      summary: Sync text ingest
      operationId: ingestText
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text: { type: string }
                externalId:
                  type: string
                  maxLength: 256
                  description: >
                    Optional caller correlation handle; echoed on GET /records/{recordId}
                    and folded into dedup. See the file ingest endpoint for details.
              required: [text]
      responses:
        '200':
          description: Normalized record created or deduplicated
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/IngestSyncPayload' }
                required: [status, payload]
        '429':
          description: >-
            MONTHLY_POOL_EXCEEDED — a hard-pool license has already spent a
            monthly pool; starting new imports resumes next UTC period.

  /import/v1/records:
    get:
      summary: Browse normalized records for the license tenant
      operationId: browseRecords
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0 }
      responses:
        '200':
          description: Paginated record list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RecordsBrowseResponse' }

  /import/v1/records/{recordId}:
    get:
      summary: Get one normalized record (includes sheets for spreadsheets)
      operationId: getRecord
      parameters:
        - name: recordId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: externalId
          in: query
          description: >
            Optional sub-partition scope. When set, records whose ingest
            externalId differs return 404 (same as cross-license isolation).
          schema: { type: string, minLength: 1 }
      responses:
        '200':
          description: Record detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/ImportRecord' }
                required: [status, payload]
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/records/{recordId}/mapping-session:
    get:
      summary: Read wizard resume state
      operationId: getMappingSession
      parameters:
        - $ref: '#/components/parameters/RecordId'
      responses:
        '200':
          description: Stored session or null
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { type: ['object', 'null'], additionalProperties: true }
                required: [status, payload]
    patch:
      summary: Upsert wizard resume state
      operationId: patchMappingSession
      parameters:
        - $ref: '#/components/parameters/RecordId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                session: { type: object, additionalProperties: true }
              required: [session]
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }

  /import/v1/records/{recordId}/sheet-rows:
    get:
      summary: Re-decode full spreadsheet rows for one sheet
      operationId: getRecordSheetRows
      description: >
        Persisted normalized records only carry redacted sample rows; this
        streams full rows from the original bytes for wizard review/apply
        (review-grid-step). Bytes are re-decoded per request, never stored.
        Pagination (additive, streaming-scale Phase 1): the 10000-row
        per-request guard stays; pass `offset` to page — `truncated: true`
        means more rows exist PAST the returned window (offset + returned <
        rowCount), so a 100k sheet reads fully in 10 calls and paged windows
        reassemble byte-identically to the unpaged ≤10k response.
      parameters:
        - $ref: '#/components/parameters/RecordId'
        - name: sheetName
          in: query
          required: true
          schema: { type: string, minLength: 1 }
        - name: maxRows
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 10000 }
          description: Rows per window. Default 10000.
        - name: offset
          in: query
          required: false
          schema: { type: integer, minimum: 0 }
          description: Row-window start (default 0).
      responses:
        '200':
          description: Sheet rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      sheetName: { type: string }
                      rows:
                        type: array
                        items: { type: array, items: { type: object, additionalProperties: true } }
                      truncated: { type: boolean }
                      rowCount: { type: integer }
                    required: [sheetName, rows, truncated, rowCount]
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/records/{recordId}/content:
    get:
      summary: Fetch the record's original upload bytes (PDF.js preview)
      operationId: getRecordContent
      description: >
        Tier-A blob bytes (base64) for host-side PDF preview. Bytes are
        fetched from blob storage per request, never persisted in Mongo.
      parameters:
        - $ref: '#/components/parameters/RecordId'
      responses:
        '200':
          description: File bytes
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      bytesBase64: { type: string }
                      mime: { type: [string, 'null'] }
                      size: { type: integer }
                    required: [bytesBase64, mime, size]
                required: [status, payload]
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/records/{recordId}/questions:
    get:
      summary: List the record's Ambiguity-Ledger questions
      description: >
        The engine's deliberate abstentions (generic-header ties, ambiguous
        reference links, low-confidence matches) as typed, answerable
        artifacts — latest generation per identity, open first. Emitted when
        `POST /mapping/column` runs with a `recordId`, and by
        `POST /records/{recordId}/resolve`.
      operationId: listQuestions
      parameters:
        - $ref: '#/components/parameters/RecordId'
        - name: state
          in: query
          schema: { type: string, enum: [open, answered, dismissed, superseded] }
      responses:
        '200':
          description: Questions, latest generation per identity
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/ImportQuestion' }
                required: [status, payload]
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/records/{recordId}/questions/resolved-mapping:
    get:
      summary: The mapping the Ambiguity Ledger has resolved (answers act)
      description: >
        The header→column map projected from this record's answered mapping
        questions, with per-entry provenance (who decided, when). An agent
        reads this after answering ambiguities to assemble a full mapping (its
        confident auto-matches PLUS these ledger decisions); a future
        execute_import merges it. Dismissed headers contribute nothing.
      operationId: resolvedMapping
      parameters:
        - $ref: '#/components/parameters/RecordId'
      responses:
        '200':
          description: The ledger-resolved mapping
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      mapping:
                        type: object
                        additionalProperties: { type: string }
                        description: header → target column key
                      entries:
                        type: array
                        items:
                          type: object
                          properties:
                            header: { type: string }
                            columnKey: { type: string }
                            actor: { $ref: '#/components/schemas/ImportQuestionActor' }
                            answeredAt: { type: string, format: date-time }
                          required: [header, columnKey, actor, answeredAt]
                    required: [mapping, entries]
                required: [status, payload]
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/records/{recordId}/questions/resolved-references:
    get:
      summary: The reference links the Ambiguity Ledger has resolved (answers act)
      description: >
        The reference twin of resolved-mapping — projected from this record's
        answered reference questions. Each entry names the chosen target
        record id and the source rows it resolves, with actor provenance.
      operationId: resolvedReferences
      parameters:
        - $ref: '#/components/parameters/RecordId'
      responses:
        '200':
          description: The ledger-resolved reference links
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      references:
                        type: array
                        items:
                          type: object
                          properties:
                            field: { type: string }
                            entity: { type: string }
                            targetId: { type: string }
                            rows: { type: array, items: { type: string } }
                            actor: { $ref: '#/components/schemas/ImportQuestionActor' }
                            answeredAt: { type: string, format: date-time }
                          required: [field, targetId, rows, actor, answeredAt]
                    required: [references]
                required: [status, payload]
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/records/{recordId}/questions/validation-summary:
    post:
      summary: Client-side validation-error rollup → validation questions (tracker E3)
      description: >
        Validation runs CLIENT-SIDE in the widget (SlickGrid review) — the
        server never sees raw review rows, only this bounded
        per-(columnKey, rule) error rollup. Syncs the record's `validation`
        questions and returns its current questions (every kind/state), same
        shape as `GET …/questions`.
      operationId: validationSummary
      parameters:
        - $ref: '#/components/parameters/RecordId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                items:
                  type: array
                  maxItems: 40
                  items:
                    type: object
                    properties:
                      columnKey: { type: string, minLength: 1, maxLength: 200 }
                      rule: { type: string, minLength: 1, maxLength: 100 }
                      errorCount: { type: integer, minimum: 1 }
                      samples:
                        type: array
                        maxItems: 5
                        items: { type: string, minLength: 1, maxLength: 120 }
                    required: [columnKey, rule, errorCount]
                totalRows: { type: integer, minimum: 0 }
              required: [items]
      responses:
        '200':
          description: The record's current questions (every kind/state)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/ImportQuestion' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/agent-execute/settings:
    get:
      summary: The agent-commit safety switch (per tenant + sub-tenant)
      description: >
        The operator switch for agent data commits (`execute_import`). Off by
        default; a human must turn it on per scope (tenant default, or a
        sub-tenant = ingest externalId). Every response ships the plain-language
        `danger` so the choice is informed. Requires the `agentAccess` feature.
      operationId: getAgentExecuteSettings
      responses:
        '200':
          description: Danger text + per-scope settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      danger: { type: string }
                      defaultEnabled: { type: boolean }
                      settings:
                        type: array
                        items:
                          type: object
                          properties:
                            subTenant: { type: string }
                            enabled: { type: boolean }
                            updatedBy: { type: ['string', 'null'] }
                            updatedAt: { type: ['string', 'null'] }
                          required: [subTenant, enabled]
                    required: [danger, defaultEnabled, settings]
                required: [status, payload]
        '403':
          $ref: '#/components/responses/Error'
    put:
      summary: Turn agent execute on/off for a scope
      operationId: setAgentExecuteSettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                subTenant: { type: string, maxLength: 256 }
                enabled: { type: boolean }
              required: [enabled]
      responses:
        '200':
          description: The saved setting (+ danger text)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    additionalProperties: true
                  danger: { type: string }
                required: [status, payload, danger]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/records/{recordId}/decision-log:
    get:
      summary: The import's decision log (Signed Import Receipt Phase 0)
      description: >
        A read-only aggregate of what decisions were made on this import, by
        whom, with what evidence, and what was delivered — assembled from
        stores that already exist (source identity, the Ambiguity-Ledger
        question trail with actor/reason codes, and webhook delivery
        outcomes). Redaction-safe by construction (metadata + already-bounded
        evidence samples). This is the unsigned evidence spine the future
        signed, verifiable Receipt canonicalizes.
      operationId: getDecisionLog
      parameters:
        - $ref: '#/components/parameters/RecordId'
      responses:
        '200':
          description: The decision log
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      version: { type: integer }
                      recordId: { type: string }
                      tenantId: { type: string }
                      source:
                        type: ['object', 'null']
                        additionalProperties: true
                      decisions:
                        type: object
                        properties:
                          questionCounts:
                            type: object
                            additionalProperties: { type: integer }
                          mapping:
                            { type: array, items: { type: object, additionalProperties: true } }
                          references:
                            { type: array, items: { type: object, additionalProperties: true } }
                          ledger:
                            type: array
                            items: { $ref: '#/components/schemas/ImportQuestion' }
                        required: [questionCounts, mapping, references, ledger]
                      outcome:
                        type: object
                        properties:
                          deliveries:
                            { type: array, items: { type: object, additionalProperties: true } }
                        required: [deliveries]
                      generatedAt: { type: string, format: date-time }
                    required: [version, recordId, tenantId, decisions, outcome, generatedAt]
                required: [status, payload]
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/records/{recordId}/questions/{questionId}/answer:
    post:
      summary: Answer a question (human or agent actor)
      description: >
        Records the judgment on the artifact (side effects on mapping state
        are a later release). An `agent` actor requires the `agentAccess`
        license feature (else 403 `AGENT_ACCESS_REQUIRED`) AND must cite
        verbatim evidence quotes in `grounding` — every quote must appear in
        the question's own evidence (samples, candidate values/labels, subject
        anchors); ungrounded agent answers are rejected with 422
        `UNGROUNDED_AGENT_ANSWER`. Human answers need only `browserMapping`
        and are grounded by definition (the deliberate pick IS the judgment —
        E2E-022).
      operationId: answerQuestion
      parameters:
        - $ref: '#/components/parameters/RecordId'
        - name: questionId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                value: { type: string, minLength: 1, maxLength: 500 }
                actor: { $ref: '#/components/schemas/ImportQuestionActor' }
                grounding:
                  type: array
                  maxItems: 8
                  items: { type: string, minLength: 1, maxLength: 300 }
              required: [value, actor]
      responses:
        '200':
          description: The answered question
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/ImportQuestion' }
                required: [status, payload]
        '403':
          description: Agent actor without the `agentAccess` feature (`AGENT_ACCESS_REQUIRED`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          $ref: '#/components/responses/Error'
        '409':
          description: Question is not open (`QUESTION_NOT_OPEN`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '422':
          description: Ungrounded agent answer (`UNGROUNDED_AGENT_ANSWER`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /import/v1/records/{recordId}/questions/{questionId}/dismiss:
    post:
      summary: Dismiss a question ("not worth answering" is a recorded judgment)
      operationId: dismissQuestion
      parameters:
        - $ref: '#/components/parameters/RecordId'
        - name: questionId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                actor: { $ref: '#/components/schemas/ImportQuestionActor' }
                reason: { type: string, maxLength: 300 }
              required: [actor]
      responses:
        '200':
          description: The dismissed question
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/ImportQuestion' }
                required: [status, payload]
        '404':
          $ref: '#/components/responses/Error'
        '409':
          description: Question is not open (`QUESTION_NOT_OPEN`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /import/v1/records/{recordId}/apply:
    post:
      summary: Apply a reviewed import (webhook path)
      operationId: applyRecord
      description: >
        Server-side apply. `completeImportAction` is SERVER-enforced here:
        `block` → 409 (refused), `discard` → acknowledged with no delivery,
        `submit` → a signed, replay-safe webhook is delivered to every active
        endpoint and a `MappingApplied` event is emitted. Requires the
        `webhookApply` entitlement. The browser `onResults` path does NOT call
        this endpoint (there `completeImportAction` is client-advisory).
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/RecordId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApplyRequest' }
      responses:
        '200':
          description: Apply result (per-endpoint delivery outcomes)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/ApplyResult' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'
        '409':
          $ref: '#/components/responses/Error'
        '413':
          description: ROWS_LIMIT_EXCEEDED — over the license per-apply row ceiling
        '429':
          description: >-
            MONTHLY_POOL_EXCEEDED — a hard-pool license exhausted its monthly
            imports or rows pool ({meter, used, limit, period} in the body);
            resumes next UTC period. Soft (overage) licenses never return this.

  /import/v1/records/{recordId}/complete:
    post:
      summary: Record browser-apply completion for billing (browser path)
      operationId: completeRecord
      description: >
        Metering-only counterpart to `apply` for the browser `onResults` path,
        which never reaches the server apply/webhook flow and would otherwise
        be invisible to billing. Not gated on `webhookApply` — this is
        precisely the non-webhook tier. The widget calls it best-effort; a
        given import records `apply`/`apply_rows` exactly once (browser
        `onResults` or server `apply()`, never both).
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/RecordId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                rowCount:
                  type: integer
                  minimum: 0
                  description: Rows the host applied client-side.
              required: [rowCount]
      responses:
        '200':
          description: Recorded (best-effort — malformed bodies still count 0 rows)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }

  /import/v1/target-catalog/{key}/contract:
    post:
      summary: Mint a Portable Import Contract from the published catalog
      operationId: mintCatalogContract
      description: >
        Issues a signed, data-free bearer bundle (pinned catalog snapshot +
        optional saved Import Profile for one file shape + policy derived from
        the catalog's required fields), content-addressed over its canonical
        form and signed with the instance artifact key — the same signing root
        as Import Receipts. Counterparties pre-flight files against it
        entirely client-side (zero egress); reissue, not sync, is the update
        model. Requires the `catalogWrite` entitlement; a mint consumes
        nothing.
      security:
        - licenseKey: []
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, maxLength: 200 }
                fingerprint:
                  type: string
                  description: File-shape fingerprint — pins the saved Import Profile.
                maxRows: { type: integer, minimum: 1 }
                expiresAt: { type: string }
      responses:
        '200':
          description: 'The signed contract envelope: {contract, signature, keyId}'
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload: { type: object }
                required: [status, payload]
        '404':
          description: No published catalog under this key
        '403':
          description: License lacks the catalogWrite entitlement

  /import/v1/records/{recordId}/receipt:
    get:
      summary: Fetch the latest Signed Import Receipt for a record
      operationId: getRecordReceipt
      description: >
        The portable evidence envelope `{receipt, signature, keyId}` issued at
        apply time (Signed Import Receipt Phase 1): the Phase 0 decision log
        plus the outcome, canonicalized and signed with the instance's ES256 (P-256)
        key as a detached JWS. Redaction-safe by construction — safe to hand an
        auditor. Verify offline with the `import:verify-receipt` CLI or
        `POST /receipts/verify`. 404 when no receipt was issued (browser
        applies are unsigned in v1).
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/RecordId'
      responses:
        '200':
          description: The signed receipt envelope
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload:
                    type: object
                    description: 'Signed envelope: {receipt, signature: {protected, signature}, keyId}'
                required: [status, payload]
        '404':
          description: Record not found, or no receipt issued for it

  /import/v1/receipts/verify:
    post:
      summary: Verify a pasted Signed Import Receipt envelope
      operationId: verifyReceipt
      description: >
        Structural + signature verdict on an untrusted envelope — recomputes
        the receipt's canonical JSON and checks the detached JWS against the
        instance's signing keys. Always 200 with a verdict; malformed input and
        bad signatures are verdicts, never errors.
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: 'A signed receipt envelope: {receipt, signature, keyId}'
      responses:
        '200':
          description: Verification verdict
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload:
                    type: object
                    properties:
                      valid: { type: boolean }
                      keyId: { type: string }
                      reason: { type: string, enum: [bad_signature, malformed] }
                      receiptId: { type: string }
                    required: [valid]
                required: [status, payload]

  /import/v1/receipts/signing-key:
    get:
      summary: The instance's receipt verification key (public half)
      operationId: getReceiptSigningKey
      description: >
        The ES256 (P-256) public key receipts are verified against — generated once
        per instance on first use and held in the instance's own storage, so a
        self-host verifies its own receipts with no dependency on the vendor.
        Save it once for offline verification (`import:verify-receipt --key`).
      security:
        - licenseKey: []
      responses:
        '200':
          description: Verification material
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload:
                    type: object
                    properties:
                      keyId: { type: string }
                      publicKeyPem: { type: string }
                      alg: { type: string, enum: [ES256] }
                    required: [keyId, publicKeyPem, alg]
                required: [status, payload]

  /import/v1/records/{recordId}/changesets:
    post:
      summary: Stage (and deliver) a changeset for a record
      operationId: stageRecordChangeset
      description: >
        Import-as-a-Changeset: classifies the mapped rows against the supplied
        host dataset (create / update / conflict / skip on one natural key,
        with fuzzy gated by `classify.keyClass`), persists the staged changeset,
        and delivers a signed `import.changeset` webhook to every registered
        endpoint that declared the `changeset` capability. With no capable
        endpoint the changeset stays `staged` (readable + ACKable over this
        API). Conflicts surface as Ambiguity-Ledger `reference` questions.
        Requires the `changeset` license feature. Refuses more than 10000 ops
        with `CHANGESET_TOO_LARGE`.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/RecordId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: 'Contract: stageChangesetRequestSchema — {rows, classify:{keyColumn, entity, matchOn, keyClass, updateCandidateFloor?}, dataset:{entity, records}}'
      responses:
        '201':
          description: Staged (and possibly delivered) changeset with delivery outcome
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload:
                    type: object
                    description: '{attempted, delivered, changeset: ImportChangeset}'
                required: [status, payload]
        '413':
          description: CHANGESET_TOO_LARGE — over the inline ops cap
    get:
      summary: List a record's changesets (newest first)
      operationId: listRecordChangesets
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/RecordId'
      responses:
        '200':
          description: The record's changesets
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload:
                    type: array
                    items:
                      type: object
                      description: 'Contract: importChangesetSchema'
                required: [status, payload]

  /import/v1/changesets:
    get:
      summary: List the tenant's newest changesets (all records)
      description: >-
        The admin review feed — summaries newest-first across every record
        (blob-backed changesets list with empty ops and an `opsPage` marker).
      operationId: listTenantChangesets
      security:
        - licenseKey: []
      parameters:
        - name: limit
          in: query
          required: false
          description: Max entries (default 50, capped at 200).
          schema: { type: integer, minimum: 1 }
      responses:
        '200':
          description: Newest changesets for the tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload:
                    type: array
                    items:
                      type: object
                      description: 'Contract: importChangesetSchema'
                required: [status, payload]

  /import/v1/changesets/{changesetId}:
    get:
      summary: Fetch one changeset
      description: >-
        Large (blob-backed) changesets answer with an ops WINDOW plus an
        `opsPage` marker ({offset, total}); hosts MUST page the full sequence
        with opsOffset/opsLimit before merging. Inline changesets read without
        paging params return the complete artifact, unchanged.
      operationId: getChangeset
      security:
        - licenseKey: []
      parameters:
        - name: changesetId
          in: path
          required: true
          schema: { type: string }
        - name: opsOffset
          in: query
          required: false
          description: First op index of the requested window (default 0).
          schema: { type: integer, minimum: 0 }
        - name: opsLimit
          in: query
          required: false
          description: >-
            Window size (default: all ops inline; 1000 for blob-backed
            changesets).
          schema: { type: integer, minimum: 1 }
      responses:
        '200':
          description: The changeset (ops possibly windowed — see `opsPage`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload:
                    type: object
                    description: 'Contract: importChangesetSchema'
                required: [status, payload]
        '404':
          description: CHANGESET_NOT_FOUND

  /import/v1/changesets/{changesetId}/revert:
    post:
      summary: Revert a merged changeset (inverse changeset)
      operationId: revertChangeset
      description: >
        Builds the INVERSE of a merged / partially-merged changeset — updates
        restore their `before` values, creates become delete-by-natural-key ops
        — stages it as a new changeset carrying `revertOf`, and delivers it
        through the same `import.changeset` pipe (delete-carrying reverts go
        only to endpoints declaring the `delete` capability). The source moves
        to `reverted` when the host ACKs the inverse as merged. 409
        `CHANGESET_REVERT_UNSUPPORTED` when deletes are needed without the
        capability, or nothing is invertible. `bestEffort: true` flags a revert
        whose restore values came from the resolve-time snapshot rather than a
        host ACK.
      security:
        - licenseKey: []
      parameters:
        - name: changesetId
          in: path
          required: true
          schema: { type: string }
      responses:
        '201':
          description: The staged (and possibly delivered) inverse changeset
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload:
                    type: object
                    description: '{attempted, delivered, changeset, uninvertibleRows, bestEffort}'
                required: [status, payload]
        '409':
          description: CHANGESET_REVERT_UNSUPPORTED or CHANGESET_STATE_INVALID

  /import/v1/changesets/{changesetId}/ack:
    post:
      summary: Host merge ACK for a delivered changeset
      operationId: ackChangeset
      description: >
        The host reports the merge outcome — `merged`, `partially_merged`
        (with `failedRows`), or `rejected` — optionally correcting op
        `before` values with host-authoritative data, which upgrades the
        changeset baseline to `host-ack` (an exact revert base). Legal only
        from the `delivered` state; anything else is a 409
        `CHANGESET_STATE_INVALID`.
      security:
        - licenseKey: []
      parameters:
        - name: changesetId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: 'Contract: changesetAckRequestSchema — {outcome, failedRows?, corrections?, message?}'
      responses:
        '200':
          description: The changeset in its post-ACK state
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  payload:
                    type: object
                    description: 'Contract: importChangesetSchema'
                required: [status, payload]
        '409':
          description: CHANGESET_STATE_INVALID — not in `delivered`

  /import/v1/records/{recordId}/browser-authorize:
    post:
      summary: Authorize a browser apply against the server-known row count
      operationId: browserAuthorizeRecord
      description: >
        The server-side authority behind the widget's client-side row-limit
        gate on the browser (`onResults`) tier. Judges the license `rowsLimit`
        against the row count the server itself ingested (header-adjusted;
        multi-sheet records gate the named sheet, defaulting to the largest),
        and refuses the over-limit case with the same `ROWS_LIMIT_EXCEEDED`
        413 the webhook apply returns — so a modified client hits the same
        wall a stock widget shows locally. `rowsLimit` 0 authorizes without a
        ceiling. Like `complete`, not gated on `webhookApply`. Failures other
        than 413 are treated as fail-open by the widget (best-effort, no new
        availability dependency for the tier).
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/RecordId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                sheet:
                  type: string
                  maxLength: 256
                  description: >
                    The sheet the widget will apply (optional; unknown or
                    absent names fall back to the largest sheet).
                headerRow:
                  type: integer
                  minimum: 0
                  description: >
                    The widget's 0-based header-row pick — subtracted from the
                    ceiling so multi-row preambles don't 413 honest imports.
                    Clamped server-side (≤200 rows can hide behind it, which is
                    immaterial for the plan-ceiling abuse this gate stops).
      responses:
        '200':
          description: Authorized — the sheet's data-row count fits the license limit
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok] }
                  authorized: { type: boolean, enum: [true] }
                  rows:
                    type: integer
                    nullable: true
                    description: Server-computed data-row ceiling (null = no ceiling known).
                  limit:
                    type: integer
                    description: The license row limit judged against (0 = unlimited).
                required: [status, authorized, rows, limit]
        '413':
          description: >
            Over the license row limit — same body shape as the webhook
            apply's `ROWS_LIMIT_EXCEEDED` (`{status, code, message, limit,
            rows}`); the widget renders its upgrade panel from it.
        '404':
          description: Record not found (or owned by another tenant)

  /import/v1/records/{recordId}/resolve:
    post:
      summary: Resolve reference columns against in-import + host datasets
      operationId: resolveRecord
      description: >
        Reference resolution for Review (L6). In-import dedup/link is
        available to every license; `datasets`/`datasetUrls` and fuzzy/learned
        matching require the `relations` entitlement (403 otherwise). Ambiguous
        links become addressable Ambiguity-Ledger questions as a side effect.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/RecordId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                rows:
                  type: array
                  minItems: 1
                  items: { type: object, additionalProperties: true }
                columns:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/ColumnSchema' }
                datasets:
                  type: array
                  description: Host-provided existing targets, inline (small sets).
                  items:
                    type: object
                    properties:
                      entity: { type: string }
                      records:
                        type: array
                        items:
                          {
                            type: object,
                            additionalProperties: true,
                            description: 'One host record: { id, …matchOn fields }.',
                          }
                    required: [entity, records]
                datasetUrls:
                  type: array
                  description: Server-fetched datasets (requires `relations`) — keeps large target sets off the browser wire.
                  items:
                    type: object
                    properties:
                      entity: { type: string }
                      url: { type: string }
                      headers: { type: object, additionalProperties: { type: string } }
                    required: [entity, url]
                keySeparator: { type: string }
              required: [rows, columns]
      responses:
        '200':
          description: Resolved reference graph
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      rows:
                        type: array
                        items: { type: object, additionalProperties: true }
                      entities:
                        type: object
                        additionalProperties:
                          type: array
                          items:
                            type: object
                            additionalProperties: true
                            description: 'Deduped target entity record; `_ref` is its id (real when `existing`, else `tmp:…`).'
                        description: Keyed by entity id.
                      links:
                        type: array
                        items:
                          type: object
                          properties:
                            from: { type: string }
                            field: { type: string }
                            to: { type: [string, 'null'] }
                            status:
                              {
                                type: string,
                                enum: [resolved, ambiguous, created, unresolved, empty],
                              }
                            confidence: { type: number }
                            candidates:
                              type: array
                              items:
                                type: object
                                properties:
                                  to: { type: string }
                                  value: { type: string }
                                  confidence: { type: number }
                                required: [to, value, confidence]
                          required: [from, field, to, status, confidence]
                      errors:
                        type: array
                        items:
                          type: object
                          properties:
                            row: { type: integer }
                            field: { type: string }
                            value: { type: string }
                            message: { type: string }
                          required: [row, field, value, message]
                    required: [rows, entities, links, errors]
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/webhooks:
    post:
      summary: Register a host apply endpoint
      operationId: registerWebhook
      description: >
        Register a URL the service POSTs signed apply deliveries to. The signing
        `secret` is returned ONCE in the response and never read back — store it
        to verify the `X-Import-Signature` header. Requires `webhookApply`.
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
              required: [url]
      responses:
        '201':
          description: Registered (includes the one-time signing secret)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/WebhookRegistration' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
    get:
      summary: List registered webhook endpoints
      operationId: listWebhooks
      description: Endpoint summaries for the tenant (the signing secret is omitted). Requires `webhookApply`.
      security:
        - licenseKey: []
      responses:
        '200':
          description: Endpoint summaries
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookSummary' }
                required: [status, payload]
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/webhooks/deliveries:
    get:
      summary: List signed apply-delivery audit records
      operationId: listWebhookDeliveries
      description: >
        Durable audit trail of recent signed apply deliveries for the tenant
        (newest first), and the queryable set of failed deliveries to retry.
        Metadata only — the delivered row payload is never persisted. Requires
        `webhookApply`.
      security:
        - licenseKey: []
      parameters:
        - in: query
          name: recordId
          schema: { type: string }
          description: Filter to deliveries for one normalized record.
        - in: query
          name: status
          schema: { type: string, enum: [delivered, failed] }
          description: Filter by delivery outcome.
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: Delivery audit records
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookDelivery' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/webhooks/{webhookId}/rotate-secret:
    post:
      summary: Rotate a webhook signing secret
      operationId: rotateWebhookSecret
      description: >
        Replace the signing secret for an active endpoint. The new secret is
        returned once in the response — update the host verifier before the
        next delivery. Requires `webhookApply`.
      security:
        - licenseKey: []
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Rotated (includes the one-time signing secret)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/WebhookRegistration' }
                required: [status, payload]
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/webhooks/{webhookId}:
    delete:
      summary: Delete a webhook endpoint
      operationId: deleteWebhook
      security:
        - licenseKey: []
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/usage:
    get:
      summary: Read usage metering counters
      operationId: listUsage
      description: >
        Per-tenant usage counters keyed on the license, for billing/visibility.
        Counters accumulate per UTC `period` (YYYY-MM) and `metric`
        (`ingest_file`, `ingest_text`, `apply`, `apply_rows`). Any valid license
        reads its OWN usage; tenant scope comes from the license.
      security:
        - licenseKey: []
      parameters:
        - in: query
          name: period
          schema: { type: string, example: '2026-05' }
          description: Restrict to one UTC YYYY-MM billing bucket.
      responses:
        '200':
          description: Usage counters
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/UsageCounter' }
                required: [status, payload]
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/target-catalog:
    get:
      summary: List this tenant's target catalogs
      operationId: listTargetCatalogs
      description: >
        Self-serve target catalogs (TARGET_CATALOG_SELF_SERVE_PLAN). Key,
        published version, draft flag and last-scanned drift per catalog.
      security:
        - licenseKey: []
      responses:
        '200':
          description: Catalog summaries
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/TargetCatalogSummary' }
                required: [status, payload]

  /import/v1/target-catalog/{key}:
    get:
      summary: Get the published target catalog (widget runtime resolution)
      operationId: getPublishedTargetCatalog
      description: >
        The widget's runtime path. `?project=` applies that sub-project's
        stored override patch (Phase 3) — the ETag then covers both the
        catalog and the override version. Carries an `ETag: "v<version>"` (or
        `"v<version>-o<overrideVersion>"`); a matching `If-None-Match` returns
        `304` with no body.
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
        - name: project
          in: query
          required: false
          schema: { type: string }
          description: Sub-project key; falls back to the base catalog when no override is stored.
        - name: If-None-Match
          in: header
          required: false
          schema: { type: string }
      responses:
        '200':
          description: Published catalog
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/PublishedCatalogPayload' }
                required: [status, payload]
        '304':
          description: Not modified (If-None-Match matched the current ETag)
        '404':
          $ref: '#/components/responses/Error'
    delete:
      summary: Delete a catalog entirely (draft, live version, history, overrides)
      operationId: deleteTargetCatalog
      description: >
        Irreversible; embeds resolving the key get `CATALOG_NOT_FOUND`
        afterwards. Frees a `catalogsLimit` slot. Requires `catalogWrite`.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/target-catalog/{key}/settings:
    patch:
      summary: Widget-surface settings — star (zero-config default) / unlist (hide from picker)
      operationId: patchTargetCatalogSettings
      description: >
        Per-catalog flags controlling what zero-config embeds see. `starred`
        makes this key the tenant's default — embeds with no `catalog-key`
        adopt it without asking (a user's stored per-browser pick still wins);
        at most one key per tenant is starred, starring unstars the previous
        one. `unlisted` hides the key from the widget's listing and picker —
        an embed that explicitly pins it with `catalog-key` still resolves it
        (selection-surface scoping, not authorization). At least one field is
        required. Requires `catalogWrite`.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                starred: { type: boolean }
                unlisted: { type: boolean }
      responses:
        '200':
          description: Updated settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      catalogKey: { type: string }
                      starred: { type: boolean }
                      unlisted: { type: boolean }
                    required: [catalogKey, starred, unlisted]
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/target-catalog/{key}/versions:
    get:
      summary: List a catalog's version history
      operationId: listTargetCatalogVersions
      description: Live + archived snapshots, newest first (Phase 3).
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      responses:
        '200':
          description: Version history
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/CatalogVersionSummary' }
                required: [status, payload]

  /import/v1/target-catalog/{key}/rollback:
    post:
      summary: Restore an archived version as a new published version
      operationId: rollbackTargetCatalog
      description: >
        History stays immutable; the restore mints a NEW live version (an
        ETag change), so embeds pick it up on their next fetch — no cache
        purge needed. Requires `catalogWrite`.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                version: { type: integer, minimum: 1 }
              required: [version]
      responses:
        '200':
          description: The new live version
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      version: { type: integer }
                    required: [version]
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/target-catalog/{key}/overrides:
    get:
      summary: List a catalog's sub-project overrides
      operationId: listTargetCatalogOverrides
      description: Parent-managed list (Phase 3).
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      responses:
        '200':
          description: Stored overrides
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items:
                      type: object
                      properties:
                        subProject: { type: string }
                        version: { type: integer }
                        updatedAt: { type: [string, 'null'], format: date-time }
                        patch: { $ref: '#/components/schemas/TargetCatalogPatch' }
                      required: [subProject, version, updatedAt, patch]
                required: [status, payload]

  /import/v1/target-catalog/{key}/overrides/{subProject}:
    put:
      summary: Create or replace one sub-project's override patch
      operationId: putTargetCatalogOverride
      description: Requires `catalogWrite`.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
        - name: subProject
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                patch: { $ref: '#/components/schemas/TargetCatalogPatch' }
              required: [patch]
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
    delete:
      summary: Remove one sub-project's override
      operationId: deleteTargetCatalogOverride
      description: The project falls back to the base catalog. Requires `catalogWrite`.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
        - name: subProject
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/target-catalog/{key}/draft:
    get:
      summary: Get the draft + published pair (editor working view)
      operationId: getTargetCatalogDraft
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      responses:
        '200':
          description: Draft/published pair
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      draft:
                        oneOf:
                          - { $ref: '#/components/schemas/TargetCatalog' }
                          - { type: 'null' }
                      fieldMeta: { type: [object, 'null'], additionalProperties: true }
                      published:
                        oneOf:
                          - { $ref: '#/components/schemas/PublishedCatalogPayload' }
                          - { type: 'null' }
                    required: [draft, fieldMeta, published]
                required: [status, payload]
    put:
      summary: Create or replace the draft
      operationId: putTargetCatalogDraft
      description: Zod-validated; requires `catalogWrite` and (for a new key) an available `catalogsLimit` slot.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                catalog: { $ref: '#/components/schemas/TargetCatalog' }
              required: [catalog]
      responses:
        '200':
          description: Saved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/target-catalog/{key}/scan:
    post:
      summary: Scan a spec (OpenAPI/JSON Schema/GraphQL/sample/AI freeform) into a draft
      operationId: scanTargetCatalog
      description: >
        Deterministic parse when the spec shape allows it; otherwise an AI
        freeform pass curates targets/fields. The result always lands as the
        DRAFT, never published. Synchronous (bounded spec size + one bounded
        AI call). Requires `catalogWrite` and (for a new key) an available
        `catalogsLimit` slot.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      requestBody:
        required: true
        description: Provide `url` (fetched server-side, SSRF-guarded) or `text` (raw spec, up to 6 MB).
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, maxLength: 2000 }
                text: { type: string }
              description: Exactly one of `url`/`text` is expected.
      responses:
        '200':
          description: Scan report (persisted as the draft)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/ScanReport' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/target-catalog/{key}/drift-check:
    post:
      summary: Deterministically re-parse the stored spec URL and diff vs the live catalog
      operationId: driftCheckTargetCatalog
      description: >
        "Your CRM added N fields" — no AI, safe to poll. Requires the catalog
        to have been scanned from a URL (a `text`-scanned catalog has none to
        re-fetch) and `catalogWrite`.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      responses:
        '200':
          description: Drift report
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    additionalProperties: true
                    description: newFields/removedFields/changedTypes/newTargets plus specUrl and comparedTo ('published'|'draft').
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'
        '422':
          $ref: '#/components/responses/Error'

  /import/v1/target-catalog/{key}/publish:
    post:
      summary: Promote the draft to the live published version
      operationId: publishTargetCatalog
      description: Requires `catalogWrite`.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/CatalogKey'
      responses:
        '200':
          description: The new live version
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: object
                    properties:
                      version: { type: integer }
                    required: [version]
                required: [status, payload]
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/catalog-presets:
    get:
      summary: List ready-made catalog presets
      operationId: listCatalogPresets
      description: >
        Ready-made target catalogs a tenant adopts instead of shaping their
        own (onboarding "what would you like to import?", admin empty
        states). Full catalogs included so UIs can preview targets and
        fields. Own path — not under `/target-catalog` — because `{key}`
        there matches any word.
      security:
        - licenseKey: []
      responses:
        '200':
          description: The preset registry
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/CatalogPreset' }
                required: [status, payload]

  /import/v1/catalog-presets/{presetId}/adopt:
    post:
      summary: Adopt a preset as a published tenant catalog
      operationId: adoptCatalogPreset
      description: >
        Copies the preset into an ordinary tenant catalog (draft + publish,
        provenance `preset:<id>`); later preset edits never touch adopted
        catalogs. Never overwrites: an existing key (draft or published) is
        left untouched and reported with `created: false`, so retries and
        double-clicks are safe. Requires `catalogWrite` and respects the
        plan's catalog creation cap (`CATALOG_LIMIT_REACHED`).
      security:
        - licenseKey: []
      parameters:
        - name: presetId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                catalogKey:
                  type: string
                  description: Catalog key to create; defaults to the preset's `suggestedKey`.
      responses:
        '200':
          description: Adoption result
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/AdoptPresetPayload' }
                required: [status, payload]
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

  /import/v1/ai/settings:
    get:
      summary: Read the deployment's AI connection + policy settings
      operationId: getAiSettings
      description: >
        AI_SETTINGS_DB_PLAN §7. Open to any valid license, like `/ai/status`.
        The provider key is masked to a hint; the raw key never round-trips.
      security:
        - licenseKey: []
      responses:
        '200':
          description: Current settings (all-null when never configured)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/AiSettingsRead' }
                required: [status, payload]
    put:
      summary: Write the deployment's AI connection and/or policy
      operationId: putAiSettings
      description: >
        Requires the `aiConfigWrite` entitlement (minted `false` for
        public/demo embed keys — an embed key must never redirect AI egress).
        `connection.apiKey`: omit to keep the stored secret, `null` to clear
        it, a string to rotate it (encrypted at rest under a DB-held master
        key). Provide `connection` and/or `policy`.
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                connection:
                  oneOf:
                    - type: object
                      properties:
                        provider: { type: string, enum: [openai-compat, anthropic] }
                        endpoint: { type: [string, 'null'] }
                        model: { type: string }
                        models: { type: array, items: { type: string } }
                        extraBody: { type: [object, 'null'], additionalProperties: true }
                        apiKey:
                          type: [string, 'null']
                          description: Provider credential. Omit to keep, null to clear, string to rotate.
                      required: [provider, endpoint, model, models]
                    - { type: 'null' }
                policy:
                  oneOf:
                    - { $ref: '#/components/schemas/AiSettingsPolicy' }
                    - { type: 'null' }
              description: At least one of `connection`/`policy` is required.
      responses:
        '200':
          description: Updated settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/AiSettingsRead' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/ai/status:
    get:
      summary: Operator/host AI trust artifact
      operationId: getAiStatus
      description: >
        ADR 0006 §7 shadow mode: what is configured, what this tenant's
        license permits, rolling shadow-agreement evidence, and the
        promotion-gate verdict — "AI agreed with the rules N%; it leaves
        shadow when it can prove it's ready". Deliberately not gated on
        `aiAssist` — an unentitled tenant sees that, not a 403.
      security:
        - licenseKey: []
      responses:
        '200':
          description: Status payload
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/AiStatusPayload' }
                required: [status, payload]

  /import/v1/ai/preferences:
    get:
      summary: Wizard AI-assist panel — tenant preference + live status
      operationId: getAiPreferences
      description: >
        Not gated on `aiAssist`: unlicensed tenants see deterministic-only
        options and a clear entitlement line instead of a 403.
      security:
        - licenseKey: []
      responses:
        '200':
          description: Preferences payload
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/AiPreferencesPayload' }
                required: [status, payload]
    patch:
      summary: Update the tenant's AI mapping/transform mode and model choice
      operationId: patchAiPreferences
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mapping: { type: string, enum: [deterministic, ai, hybrid] }
                transform: { type: string, enum: [deterministic, ai, hybrid] }
                model:
                  type: [string, 'null']
                  description: Must be in the deployment allowlist; null resets to the default.
              description: Provide `mapping`, `transform` and/or `model`.
      responses:
        '200':
          description: Updated preferences
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/AiPreferencesPayload' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'

  /import/v1/ai/health:
    get:
      summary: Operator connectivity probe for the configured AI provider
      operationId: getAiHealth
      description: >
        One tiny live call to the configured provider, classified so a wrong
        endpoint/key/exhausted quota is diagnosable without container logs.
        Reports deployment-wide provider config, not tenant data. Never a
        403 — `not_configured` is a normal verdict.
      security:
        - licenseKey: []
      responses:
        '200':
          description: Health payload
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/AiProviderHealth' }
                required: [status, payload]

  /import/v1/ai/facts/learn:
    post:
      summary: Score human verification of an accepted fact-map import
      operationId: learnAiFacts
      description: >
        Best-effort human-feedback scoring for extracted PDF facts (fact-map
        v2 Phase 4) — never fails the caller. Requires `browserMapping`.
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                runId: { type: string }
                schemaFingerprint: { type: string }
                props:
                  type: array
                  minItems: 1
                  maxItems: 64
                  items:
                    type: object
                    properties:
                      propKey: { type: string }
                      value: { type: string }
                      confirmed: { type: boolean }
                      edited: { type: boolean }
                    required: [propKey, value, confirmed, edited]
              required: [props]
      responses:
        '200':
          description: Recorded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/ai/transform-plan:
    post:
      summary: Plan a Transform Recipe from a natural-language instruction
      operationId: planTransformRecipe
      description: >
        AI transform planning (the "AI cleaning" feature). The model returns a
        PLAN — a Transform Recipe over a closed vocabulary of pure operations —
        never edited data: execution happens client-side, deterministically,
        after a human accepts the diff. The planner sees column metadata and
        redacted sample values only. Requires the `cleaningAssistant` license
        feature (403 without it); the operator mode ceiling and the tenant's
        transform preference degrade softly to `status: 'skipped'` with a
        reason ("AI cleaning is off") and no provider call. Platform-billed
        deployments meter this against the monthly AI-run quota.
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TransformPlanRequest' }
      responses:
        '200':
          description: Planning outcome (soft failures are statuses, not HTTP errors)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/TransformPlanPayload' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/ai/extract-facts:
    post:
      summary: Schema-first semantic fact extraction for one PDF record
      operationId: extractAiFacts
      description: >
        Fact-map v2 Phase 1: one model call driven by the host's column
        schema. With AI off (deterministic mode / no provider) the catalog
        vocabulary still pairs document labels with values — no LLM involved
        (`ai.model: null`). Degrades non-silently: missing entitlement/text
        comes back as `status: 'skipped'` with a reason — never a 403.
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                recordId: { type: string }
                columns:
                  type: array
                  maxItems: 64
                  items:
                    type: object
                    properties:
                      key: { type: string }
                      label: { type: string }
                      columnType: { type: string }
                      required: { type: boolean }
                      scope:
                        type: string
                        enum: [row, document]
                        description: Composed document import — document-scope columns are the extraction targets.
                      alternativeMatches:
                        type: array
                        maxItems: 64
                        items: { type: string }
                        description: Catalog aliases — vocabulary for the deterministic label→value path.
                    required: [key, label]
              required: [recordId, columns]
      responses:
        '200':
          description: Extraction outcome (soft failures are statuses, not HTTP errors)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/ExtractFactsPayload' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'

  /import/v1/transform/recipes:
    post:
      summary: Save an accepted cleanup recipe (Import Profile v0)
      operationId: saveTransformRecipe
      description: >
        Human Accept is the ONLY write path into the per-tenant recipe store.
        The stored artifact is an Import Profile (recipe + optional mapping +
        actor provenance), schema-validated fail-closed and stamped with the
        engine version at write. One profile per (tenant, fingerprint) — the
        latest accepted cleanup for a file shape wins. Requires `browserMapping`.
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fingerprint: { type: string, description: File-shape identity (headersIdentifier). }
                recipe: { $ref: '#/components/schemas/TransformRecipe' }
                title: { type: string, maxLength: 120 }
                mapping:
                  type: object
                  additionalProperties: { type: string }
                actor: { type: string, maxLength: 200, description: Opaque host actor for audits. }
                runId: { type: string, description: Planner run this recipe came from. }
                model: { type: [string, 'null'] }
                promptVersion: { type: [string, 'null'] }
              required: [fingerprint, recipe]
      responses:
        '200':
          description: The stored profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/StoredTransformRecipe' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
    get:
      summary: List saved cleanup recipes (audit + replay lookup)
      operationId: listTransformRecipes
      security:
        - licenseKey: []
      parameters:
        - in: query
          name: fingerprint
          schema: { type: string }
          description: Restrict to one file shape (the widget's replay-chip lookup).
      responses:
        '200':
          description: Saved profiles, newest accepted first
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload:
                    type: array
                    items: { $ref: '#/components/schemas/StoredTransformRecipe' }
                required: [status, payload]
        '403':
          $ref: '#/components/responses/Error'
    delete:
      summary: Purge saved recipes (all, or one file shape)
      operationId: purgeTransformRecipes
      security:
        - licenseKey: []
      parameters:
        - in: query
          name: fingerprint
          schema: { type: string }
      responses:
        '200':
          description: Deletion count
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  deleted: { type: integer }
                required: [status, deleted]
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/transform/recipes/{id}:
    delete:
      summary: Purge one saved recipe (right-to-forget)
      operationId: purgeTransformRecipe
      security:
        - licenseKey: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deletion count
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  deleted: { type: integer }
                required: [status, deleted]
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/transform/recipes/{id}/replayed:
    post:
      summary: Count a replay application (an LLM call avoided)
      operationId: recordTransformReplay
      security:
        - licenseKey: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Counted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/transform/feedback:
    post:
      summary: Accept/Reject verdict on a transform planner run
      operationId: transformFeedback
      description: >
        The human half of the trust loop (§5.4) — annotates the decision-log
        run with the verdict (and optional opaque actor).
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                runId: { type: string }
                verdict: { type: string, enum: [accepted, rejected] }
                actor: { type: string, maxLength: 200 }
              required: [runId, verdict]
      responses:
        '200':
          description: Recorded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ok' }
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/transform/dry-run:
    post:
      summary: Stateless recipe dry-run (host CI contract)
      operationId: dryRunTransform
      description: >
        Recipe + sample rows → the exact deterministic diff the widget's
        preview shows, incl. the post-check floor when `columns` provide the
        validator context. Requires `cleaningAssistant`. Max 500 rows.
      security:
        - licenseKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                recipe: { $ref: '#/components/schemas/TransformRecipe' }
                rows:
                  type: array
                  maxItems: 500
                  items: { type: object, additionalProperties: true }
                columns:
                  type: array
                  items: { type: object, additionalProperties: true }
                frames:
                  type: object
                  description: >
                    Named inline reference frames for dynamic_lookup steps
                    (max 10000 rows each).
                  additionalProperties:
                    type: object
                    properties:
                      rows:
                        type: array
                        items: { type: object, additionalProperties: true }
                    required: [rows]
              required: [recipe, rows]
      responses:
        '200':
          description: The diff
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/TransformRunResult' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'

  /import/v1/records/{recordId}/transform:
    post:
      summary: Execute a recipe headlessly (same engine as the widget)
      operationId: transformRecord
      description: >
        Runs a stored (`fingerprint` — counted as a replay) or inline recipe.
        Rows come inline, or — for spreadsheet records — from a server-side
        re-decode via `mapping` + `sheetName`. Pass `columns` whenever the
        recipe gates on `when: "invalid"`. Requires `cleaningAssistant`.
      security:
        - licenseKey: []
      parameters:
        - $ref: '#/components/parameters/RecordId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                recipe: { $ref: '#/components/schemas/TransformRecipe' }
                fingerprint: { type: string }
                rows:
                  type: array
                  maxItems: 10000
                  items: { type: object, additionalProperties: true }
                mapping:
                  type: object
                  additionalProperties: { type: string }
                sheetName: { type: string }
                headerRowIndex: { type: integer, minimum: 0 }
                columns:
                  type: array
                  items: { type: object, additionalProperties: true }
                includeRows: { type: boolean }
                frames:
                  type: object
                  description: >
                    Named reference frames for dynamic_lookup steps: inline
                    rows, or `sheetName` (+ optional `headerRowIndex`) naming
                    another sheet of the SAME record — its header row provides
                    the frame's column names. Max 10000 rows per frame.
                  additionalProperties:
                    type: object
                    properties:
                      rows:
                        type: array
                        items: { type: object, additionalProperties: true }
                      sheetName: { type: string }
                      headerRowIndex: { type: integer, minimum: 0 }
      responses:
        '200':
          description: Execution result
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: ok }
                  payload: { $ref: '#/components/schemas/TransformRunResult' }
                required: [status, payload]
        '400':
          $ref: '#/components/responses/Error'
        '403':
          $ref: '#/components/responses/Error'
        '404':
          $ref: '#/components/responses/Error'

components:
  parameters:
    RecordId:
      name: recordId
      in: path
      required: true
      schema: { type: string, format: uuid }

    CatalogKey:
      name: key
      in: path
      required: true
      schema: { type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$', maxLength: 128 }

  securitySchemes:
    licenseKey:
      type: http
      scheme: bearer
      description: Per-tenant signed license key. Tenant context is derived from the key.

  requestBodies:
    Telemetry:
      required: true
      content:
        application/json:
          schema:
            type: object
            description: Fire-and-forget telemetry event (arbitrary JSON object).
            additionalProperties: true

  responses:
    Error:
      description: Error envelope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

  schemas:
    Ok:
      type: object
      properties:
        status: { type: string, const: ok }
      required: [status]

    ErrorResponse:
      type: object
      properties:
        status: { type: string, const: error }
        code: { type: string, example: BAD_REQUEST }
        message: { type: string }
        issues:
          type: array
          description: Zod issues (path/message/code), when the failure is a validation error.
          items: { type: object, additionalProperties: true }
      required: [status, code]

    ImportQuestionActor:
      type: object
      properties:
        kind: { type: string, enum: [human, agent] }
        id:
          type: string
          description: Opaque actor hint (user label, agent client id) for audits.
      required: [kind]

    ImportQuestion:
      type: object
      description: >
        An Ambiguity-Ledger artifact — a deliberate engine abstention turned
        into a typed, answerable question. Prompts are template-generated
        (deterministic, never model prose); `identity` is stable per
        (kind, subject) so state survives re-matching; the latest `generation`
        per identity is current.
      properties:
        id: { type: string }
        recordId: { type: string }
        identity: { type: string }
        generation: { type: integer, minimum: 0 }
        kind: { type: string, enum: [mapping, reference, validation, fact] }
        state: { type: string, enum: [open, answered, dismissed, superseded] }
        subject:
          type: object
          additionalProperties: true
          description: What the question is about (header, entity/field, rowRefs, …).
        promptKey: { type: string, example: question.mapping.generic_header }
        prompt: { type: string }
        evidence:
          type: object
          properties:
            reasonCodes: { type: array, items: { type: string } }
            samples: { type: array, items: { type: string } }
            candidates:
              type: array
              items:
                type: object
                properties:
                  value: { type: string }
                  label: { type: string }
                  confidence: { type: number }
                  reasonCodes: { type: array, items: { type: string } }
                required: [value]
          required: [candidates]
        cost: { type: string, enum: [low, medium, high] }
        evidenceFingerprint: { type: string }
        answer:
          type: object
          properties:
            value: { type: string }
            actor: { $ref: '#/components/schemas/ImportQuestionActor' }
            grounding: { type: array, items: { type: string } }
            answeredAt: { type: string, format: date-time }
          required: [value, actor, answeredAt]
        dismissedBy: { $ref: '#/components/schemas/ImportQuestionActor' }
        dismissReason: { type: string }
        supersededReason: { type: string, enum: [resolved-in-flow] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required:
        [
          id,
          recordId,
          identity,
          generation,
          kind,
          state,
          subject,
          promptKey,
          prompt,
          evidence,
          cost,
          evidenceFingerprint,
          createdAt,
          updatedAt,
        ]

    LicenseFeatures:
      type: object
      properties:
        serverMapping: { type: boolean }
        autoMapping: { type: boolean }
        browserMapping: { type: boolean }
        webhookApply: { type: boolean }
        i18n: { type: boolean }
        customStyle: { type: boolean }
        cleaningAssistant: { type: boolean }
        agentAccess:
          type: boolean
          description: >
            Agent write surface (answer/dismiss ledger questions; future
            execute). Enterprise-only, off by default incl. on pre-existing
            enterprise tokens (opt-in by re-mint).
        semanticAssist:
          type: boolean
          description: >
            Semantic assist — meaning-based match suggestions (gates live
            emission only; the observation-only shadow pass is not
            license-gated). Business + enterprise plans; absent on older
            tokens → false (gained at re-mint).
      required:
        [
          serverMapping,
          autoMapping,
          browserMapping,
          webhookApply,
          i18n,
          customStyle,
          cleaningAssistant,
        ]

    LicenseLimits:
      type: object
      description: Per-tenant quotas; 0 = unlimited.
      properties:
        uploadSizeBytes: { type: integer }
        rowsLimit: { type: integer }
        rateLimitPerMin: { type: integer }
      required: [uploadSizeBytes, rowsLimit, rateLimitPerMin]

    LicenseEntitlements:
      type: object
      properties:
        plan: { type: string }
        features: { $ref: '#/components/schemas/LicenseFeatures' }
        limits: { $ref: '#/components/schemas/LicenseLimits' }
      required: [plan, features, limits]

    LicenseVerifyPayload:
      type: object
      properties:
        verified: { type: boolean }
        development: { type: boolean }
        tenantId:
          type: [string, 'null']
          description: Service-owned opaque tenant id (null when unverified).
        licenseSub:
          type: [string, 'null']
          description: Alias for `tenantId` — the license `sub` (null when unverified).
        entitlements:
          oneOf:
            - { $ref: '#/components/schemas/LicenseEntitlements' }
            - { type: 'null' }
        expiresAt:
          type: [integer, 'null']
          description: Expiry, epoch seconds (null when unverified).
      required: [verified, development, tenantId, licenseSub, entitlements, expiresAt]

    AutoMapping:
      type: object
      properties:
        identifier: { type: string }
        mapping:
          description: The host's saved column-mapping layout (opaque to the service).
      required: [identifier, mapping]

    ColumnType:
      type: string
      description: Semantic type of a target column.
      enum:
        - string
        - int
        - float
        - percentage
        - boolean
        - category
        - date
        - date_dmy
        - date_mdy
        - date_iso
        - datetime
        - time_hms
        - time_hms_24
        - time_hm
        - time_hm_24
        - email
        - url
        - url_www
        - url_https
        - phone
        - iban
        - bic
        - vat_eu
        - gtin
        - zip_code_de
        - country_code_alpha_2
        - country_code_alpha_3
        - currency_code
        - currency_eur
        - currency_usd

    ColumnValidator:
      type: object
      properties:
        validate:
          type: string
          enum:
            - required
            - unique
            - regex
            - required_with
            - required_without
            - required_with_all
            - required_without_all
            - required_with_values
            - required_without_values
            - required_with_all_values
            - required_without_all_values
        regex: { type: [string, 'null'] }
        columns:
          type: array
          items: { type: string }
        errorMessage: { type: string }
        severity:
          type: string
          enum: [error, warning]
          description: >-
            Review-gate override. `error` blocks Next until fixed (declare for
            constraints the destination system hard-enforces); `warning` tints
            but never blocks. Absent → required* validators block, other
            checks block on required columns and warn on optional ones.
      required: [validate]

    DropdownOption:
      type: object
      description: Allowed value for a `category` column (discriminated by `type`).
      properties:
        type: { type: string, enum: [string, int, float] }
        value:
          oneOf:
            - { type: string }
            - { type: number }
        label: { type: string }
        alternativeMatches:
          type: array
          items: { type: string }
        description: { type: string }
      required: [type, value, label]

    ColumnSchema:
      type: object
      description: One target column in the host's Target Data Model. Mirrors @capitality-io/mildport-contract.
      properties:
        key:
          type: string
          description: Host target field. Opaque to the service (host owns dotted paths).
        label: { type: string }
        description: { type: string }
        example: { type: [string, 'null'] }
        columnSize: { type: [integer, 'null'] }
        columnType:
          allOf: [{ $ref: '#/components/schemas/ColumnType' }]
          default: string
        validations:
          type: [array, 'null']
          items: { $ref: '#/components/schemas/ColumnValidator' }
        dropdownOptions:
          type: [array, 'null']
          items: { $ref: '#/components/schemas/DropdownOption' }
        alternativeMatches:
          type: array
          items: { type: string }
        valueDetectors:
          type: array
          items: { $ref: '#/components/schemas/ValueDetector' }
          description: Host-declared value-based matching detectors (roadmap L3).
        isMultiSelect: { type: boolean }
        outputFormat: { type: [string, 'null'] }
        allowCustomOptions: { type: boolean }
        numberFormat: { type: string, enum: [eu, us] }
        optionMappingMode: { type: string, enum: [smart, exact] }
        hidden: { type: boolean }
        disabled: { type: boolean }
      required: [key, label]

    ValueDetector:
      type: object
      description: >-
        Host-declared value-based matching detector (roadmap L3) — resolves
        cryptic headers by what the cell values look like. One of three kinds.
      oneOf:
        - title: builtin
          properties:
            kind: { type: string, const: builtin }
            name: { $ref: '#/components/schemas/ColumnType' }
            minRatio: { type: number, minimum: 0, maximum: 1 }
            weight: { type: number, minimum: 0, maximum: 100 }
          required: [kind, name]
        - title: regex
          properties:
            kind: { type: string, const: regex }
            pattern: { type: string, minLength: 1, maxLength: 512 }
            flags: { type: string, maxLength: 8 }
            minRatio: { type: number, minimum: 0, maximum: 1 }
            weight: { type: number, minimum: 0, maximum: 100 }
          required: [kind, pattern]
        - title: oneOf
          properties:
            kind: { type: string, const: oneOf }
            values: { type: array, items: { type: string, minLength: 1 }, minItems: 1 }
            caseInsensitive: { type: boolean }
            minRatio: { type: number, minimum: 0, maximum: 1 }
            weight: { type: number, minimum: 0, maximum: 100 }
          required: [kind, values]

    ColumnMappingRequest:
      type: object
      properties:
        columns:
          type: array
          items: { $ref: '#/components/schemas/ColumnSchema' }
        recordId:
          type: string
          description: >-
            Optional normalized-record id. When present (and owned by the
            requesting tenant), the match ALSO emits Ambiguity-Ledger
            questions for the record (GET /records/{recordId}/questions) —
            the matcher's deliberate abstentions become addressable
            artifacts. Absent → matching behaves exactly as before and
            nothing is emitted.
        inputHeaders:
          type: array
          items: { type: string }
          description: Source spreadsheet header cells to score against the columns.
        inputSamples:
          type: array
          items:
            type: array
            items: { type: string }
          description: >-
            Optional preview cell values per header (index-aligned with
            inputHeaders) for value-based detection (L3). Used transiently for
            scoring; never persisted.
        keySeparator:
          type: string
          minLength: 1
          description: >-
            Separator that splits a column key into entity/path segments
            (default "."). Set it to match the host's columnsFromZod keyJoiner so
            deep keys (person/customFields/options) parse correctly.
        options:
          type: object
          properties:
            licenseKey: { type: string }
            enableRememberMapping: { type: boolean }
            originRequest: { type: string }
      required: [columns, inputHeaders]

    ImportIdentityEcho:
      type: object
      properties:
        licenseSub:
          type: string
          description: License `sub` — the service-owned opaque tenant id.
        externalId:
          type: [string, 'null']
          description: Caller correlation id from the ingest request, when supplied.
      required: [licenseSub, externalId]

    IngestSyncPayload:
      allOf:
        - type: object
          properties:
            mode: { type: string, const: sync }
            envelopeId: { type: string, format: uuid }
            recordId: { type: string, format: uuid }
            deduplicated: { type: boolean }
          required: [mode, envelopeId, recordId, deduplicated]
        - { $ref: '#/components/schemas/ImportIdentityEcho' }

    IngestAsyncPayload:
      allOf:
        - type: object
          properties:
            mode: { type: string, const: async }
            jobId: { type: string }
            statusUrl: { type: string }
          required: [mode, jobId, statusUrl]
        - { $ref: '#/components/schemas/ImportIdentityEcho' }

    IngestFileResult:
      oneOf:
        - { $ref: '#/components/schemas/IngestSyncPayload' }
        - { $ref: '#/components/schemas/IngestAsyncPayload' }

    IngestJobStatus:
      type: object
      properties:
        jobId: { type: string }
        status:
          type: string
          enum: [queued, running, completed, failed]
        envelopeId: { type: string, format: uuid }
        recordId: { type: string, format: uuid }
        deduplicated: { type: boolean }
        errorCode: { type: string }
        errorMessage: { type: string }
      required: [jobId, status]

    CompleteImportAction:
      type: string
      description: >
        Host's decision for the reviewed import. Server-enforced on the webhook
        apply path; client-advisory on the browser path.
      enum: [submit, discard, block]

    LearnedAlias:
      type: object
      description: One learned header→column alias (the learning audit surface).
      properties:
        columnKey:
          type: string
          description: Target column key the alias maps to.
        header:
          type: string
          description: The learned source header text, as originally seen.
        hits:
          type: integer
          description: How many grounded confirmations this pair has received.
        lastSeenAt:
          type: string
          description: ISO-8601 instant of the most recent confirmation.
        matcherVersion:
          type: [integer, 'null']
          description: Matcher algorithm version that last confirmed this alias.
      required: [columnKey, header, hits, lastSeenAt]

    ApplyRequest:
      type: object
      properties:
        action: { $ref: '#/components/schemas/CompleteImportAction' }
        rows:
          type: array
          description: Reviewed, already-mapped result rows (required for `submit`).
          items: { type: object, additionalProperties: true }
        mapping:
          type: object
          description: Source-header → target-column-key map (host audit context).
          additionalProperties: { type: string }
        groundedMappingHeaders:
          type: array
          maxItems: 500
          items: { type: string }
          description: >
            Source headers a HUMAN actively mapped or confirmed. Alias learning
            from this apply is grounded on these pairs only; when absent the
            apply learns nothing (fail-closed — auto-assignments never teach
            the engine its own guesses).
        meta:
          type: object
          description: Opaque host context echoed into the webhook payload.
          additionalProperties: true
      required: [action]

    ApplyDelivery:
      type: object
      description: Per-endpoint delivery outcome.
      properties:
        webhookId: { type: string }
        url: { type: string }
        delivered: { type: boolean }
        responseStatus: { type: integer }
        attempts: { type: integer }
        error: { type: string }
      required: [webhookId, url, delivered, attempts]

    ApplyResult:
      type: object
      properties:
        recordId: { type: string }
        action: { $ref: '#/components/schemas/CompleteImportAction' }
        deliveryId:
          type: string
          description: Correlation + idempotency id reused across all deliveries for this apply.
        rowCount: { type: integer }
        deliveries:
          type: array
          items: { $ref: '#/components/schemas/ApplyDelivery' }
      required: [recordId, action, deliveryId, rowCount, deliveries]

    WebhookSummary:
      type: object
      properties:
        webhookId: { type: string }
        url: { type: string }
        active: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [webhookId, url, active, createdAt, updatedAt]

    WebhookRegistration:
      allOf:
        - { $ref: '#/components/schemas/WebhookSummary' }
        - type: object
          properties:
            secret:
              type: string
              description: One-time HMAC signing secret. Stored ONLY in this response.
          required: [secret]

    UsageCounter:
      type: object
      description: One usage counter for a (tenant, period, metric).
      properties:
        tenantId: { type: string }
        period: { type: string, example: '2026-05' }
        metric:
          type: string
          enum: [ingest_file, ingest_text, apply, apply_rows]
        quantity: { type: integer }
        licenseId: { type: string }
        updatedAt: { type: string, format: date-time }
      required: [tenantId, period, metric, quantity, updatedAt]

    WebhookDelivery:
      type: object
      description: One persisted apply-delivery outcome (metadata only; no row payload).
      properties:
        tenantId: { type: string }
        deliveryId: { type: string }
        webhookId: { type: string }
        recordId: { type: string }
        event: { type: string }
        url: { type: string }
        status: { type: string, enum: [delivered, failed] }
        attempts: { type: integer }
        responseStatus: { type: integer }
        error: { type: string }
        rowCount: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required:
        - tenantId
        - deliveryId
        - webhookId
        - recordId
        - event
        - url
        - status
        - attempts
        - rowCount
        - createdAt
        - updatedAt

    ImportRecord:
      type: object
      description: Normalized record summary for the import wizard.
      properties:
        recordId: { type: string, format: uuid }
        envelopeId: { type: string, format: uuid }
        kind: { type: string }
        externalId:
          type: [string, 'null']
          description: Correlation handle supplied at ingest, echoed back. Null when none was given.
        sheets:
          type: [array, 'null']
          items: { type: object, additionalProperties: true }
      required: [recordId, envelopeId, kind, externalId]

    RecordsBrowseResponse:
      type: object
      properties:
        status: { type: string, const: ok }
        payload:
          type: array
          items: { $ref: '#/components/schemas/ImportRecord' }
        total: { type: integer }
        offset: { type: integer }
        limit: { type: integer }
      required: [status, payload, total, offset, limit]

    ColumnMappingRow:
      type: object
      properties:
        inputColumnKey: { type: string }
        columnSuggestions:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              percentage: { type: number }
              probability:
                type: number
                description: >
                  Calibrated P(this pick is right), 0–1 — the same number the
                  Ambiguity Ledger's ask-gate uses; board and ledger can never
                  disagree.
              matchedOn: { type: string }
              reasonCodes:
                type: array
                items: { type: string }
                description: Evidence tags ("exact", "synonym", …) — what fired, never how scoring works.
            required: [key, percentage]
      required: [inputColumnKey, columnSuggestions]

    TransformRecipeStep:
      type: object
      description: >
        One step of a Transform Recipe. `op` is a CLOSED vocabulary — the
        entire attack/test surface; params are op-specific and re-validated
        server- and client-side (`@capitality-io/mildport-transform`).
      properties:
        op:
          type: string
          enum:
            [
              trim,
              collapse_whitespace,
              case,
              email_normalize,
              replace,
              regex_replace,
              split_name,
              split,
              concat,
              date_reformat,
              number_normalize,
              phone_e164,
              map_values,
              set_default,
            ]
        column: { type: string, description: Source column key (target-side, post-mapping). }
        target: { type: string, description: Optional write-to column; defaults to `column`. }
        when:
          type: string
          enum: [always, blank, invalid, matches]
        whenPattern: { type: string, maxLength: 512 }
        params: { type: object, additionalProperties: true }
      required: [op, column]

    TransformRecipe:
      type: object
      description: >
        A declarative, deterministic data-cleaning program. Stored and
        replayed verbatim; `minEngine` makes replays refuse (never silently
        differ) on an older engine.
      properties:
        version: { type: integer, const: 1 }
        minEngine: { type: integer }
        title: { type: string, maxLength: 120 }
        steps:
          type: array
          minItems: 1
          maxItems: 30
          items: { $ref: '#/components/schemas/TransformRecipeStep' }
      required: [version, steps]

    TransformPlanRequest:
      type: object
      description: >
        Provide `instruction` (natural language, any language) and/or
        `columnStats` (validation-driven suggestion mode). Sample values are
        redacted server-side before any model egress.
      properties:
        instruction: { type: string, maxLength: 2000 }
        columns:
          type: array
          minItems: 1
          maxItems: 64
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
              columnType: { type: string }
              validations:
                type: array
                maxItems: 8
                items: { type: string }
            required: [key, label]
        columnStats:
          type: array
          maxItems: 64
          items:
            type: object
            properties:
              column: { type: string }
              rowCount: { type: integer, minimum: 0 }
              invalidCount: { type: integer, minimum: 0 }
              blankCount: { type: integer, minimum: 0 }
              samples:
                type: array
                maxItems: 8
                items: { type: string, maxLength: 200 }
            required: [column]
        locale: { type: string, maxLength: 10 }
      required: [columns]

    TransformPlanPayload:
      type: object
      description: >
        Planning outcome. `status: ok` with `recipe: null` is an honest
        abstention (`reason` carries the model's own explanation); gating
        (`skipped`), quota (`budget_exceeded`), `rate_limited`, `timeout` and
        `error` degrade softly — the deterministic cleanup tools always remain.
      properties:
        status:
          type: string
          enum: [ok, skipped, rate_limited, budget_exceeded, timeout, error]
        reason: { type: [string, 'null'] }
        recipe:
          oneOf:
            - { $ref: '#/components/schemas/TransformRecipe' }
            - { type: 'null' }
        rationale: { type: [string, 'null'] }
        affected:
          type: [object, 'null']
          description: >
            Server-side ESTIMATE from the submitted column stats; the widget's
            client-side dry-run computes the authoritative diff.
          properties:
            cells: { type: [integer, 'null'] }
            columns:
              type: array
              items: { type: string }
          required: [cells, columns]
        ai:
          type: [object, 'null']
          properties:
            runId: { type: [string, 'null'] }
            mode: { type: string, enum: [deterministic, hybrid, ai] }
            model: { type: [string, 'null'] }
            promptVersion: { type: string, example: transform-planner/v1 }
            cacheHit: { type: boolean }
            tokens:
              type: [object, 'null']
              properties:
                input: { type: integer }
                output: { type: integer }
              required: [input, output]
            latencyMs: { type: [integer, 'null'] }
          required: [runId, mode, model, promptVersion, cacheHit, tokens, latencyMs]
      required: [status, reason, recipe, rationale, affected, ai]

    StoredTransformRecipe:
      type: object
      description: >
        One saved cleanup — an Import Profile v0 artifact plus store metadata.
        `hits` counts replays (each one is an LLM call avoided).
      properties:
        id: { type: string }
        fingerprint: { type: string }
        profile:
          type: object
          description: The Import Profile v0 artifact (value-free, versioned).
          properties:
            version: { type: integer }
            fingerprint: { type: string }
            title: { type: string }
            actor: { type: string }
            createdAt: { type: string }
            mapping:
              type: object
              additionalProperties: { type: string }
            recipe: { $ref: '#/components/schemas/TransformRecipe' }
          required: [version]
        engineVersion: { type: integer }
        promptVersion: { type: [string, 'null'] }
        model: { type: [string, 'null'] }
        hits: { type: integer }
        lastAcceptedAt: { type: string }
        lastReplayedAt: { type: [string, 'null'] }
      required: [id, fingerprint, profile, engineVersion, hits, lastAcceptedAt]

    TransformRunResult:
      type: object
      description: Deterministic engine outcome (dry-run and headless execute).
      properties:
        status: { type: string, const: ok }
        rowCount: { type: integer }
        stats:
          type: object
          properties:
            cellsChanged: { type: integer }
            rowsAffected: { type: integer }
            columnsChanged:
              type: array
              items: { type: string }
            rowsRemoved: { type: integer }
          required: [cellsChanged, rowsAffected, columnsChanged, rowsRemoved]
        changes:
          type: array
          items:
            type: object
            properties:
              rowIndex: { type: integer }
              column: { type: string }
              from: { type: string }
              to: { type: string }
              stepIndex: { type: integer }
            required: [rowIndex, column, from, to, stepIndex]
        changesTruncated: { type: boolean }
        removedRows:
          type: array
          description: >
            Rows dropped by filter-class steps (row_filter, dedup_rows) —
            original input indexes; duplicateOf names the kept row for
            duplicates. Capped like `changes`.
          items:
            type: object
            properties:
              rowIndex: { type: integer }
              stepIndex: { type: integer }
              duplicateOf: { type: integer }
            required: [rowIndex, stepIndex]
        removedRowsTruncated: { type: boolean }
        warnings:
          type: array
          items:
            type: object
            properties:
              stepIndex: { type: integer }
              message: { type: string }
              rowIndex: { type: integer }
              column: { type: string }
            required: [stepIndex, message]
        newlyInvalid: { type: integer }
        blockedSteps:
          type: array
          items: { type: integer }
        rows:
          type: array
          items: { type: object, additionalProperties: true }
        recipeId: { type: string }
      required:
        [
          status,
          rowCount,
          stats,
          changes,
          changesTruncated,
          removedRows,
          removedRowsTruncated,
          warnings,
        ]

    TargetField:
      type: object
      description: >
        One field on a target/record type (TARGET_CATALOG_SELF_SERVE_PLAN
        "Target Data Model" — mirrors @capitality-io/mildport-contract
        `targetFieldSchema`). Same shape as ColumnSchema plus `required`
        sugar and `reference` (L6 relationship resolution).
      properties:
        key:
          { type: string, description: Stable field key, opaque to Mildport (e.g. contact.email). }
        label: { type: string, description: Falls back to a prettified key segment when omitted. }
        required: { type: boolean }
        description: { type: string }
        example: { type: [string, 'null'] }
        columnSize: { type: [integer, 'null'] }
        columnType:
          allOf: [{ $ref: '#/components/schemas/ColumnType' }]
          default: string
        validations:
          type: [array, 'null']
          items: { $ref: '#/components/schemas/ColumnValidator' }
        alternativeMatches: { type: array, items: { type: string } }
        dropdownOptions:
          type: [array, 'null']
          items: { $ref: '#/components/schemas/DropdownOption' }
        valueDetectors:
          type: array
          items: { $ref: '#/components/schemas/ValueDetector' }
        reference:
          type: object
          additionalProperties: true
          description: L6 relationship (entity/matchOn/resolve strategy) — see docs/RELATIONSHIPS.md.
        isMultiSelect: { type: boolean }
        outputFormat: { type: [string, 'null'] }
        allowCustomOptions: { type: boolean }
        numberFormat: { type: string, enum: [eu, us] }
        optionMappingMode: { type: string, enum: [smart, exact] }
        hidden: { type: boolean }
        disabled: { type: boolean }
      required: [key]

    TargetDescriptor:
      type: object
      properties:
        id:
          {
            type: string,
            description: "Stable target/record-type id (e.g. 'contact', 'lead', 'invoice').",
          }
        label: { type: string, description: Falls back to a prettified id when omitted. }
        aliases:
          type: array
          items: { type: string }
          description: Header tokens/synonyms that identify this target during column matching.
        fields:
          type: array
          items: { $ref: '#/components/schemas/TargetField' }
      required: [id, fields]

    TargetCatalog:
      type: array
      description: The set of record types a host allows for the current capture/workflow.
      items: { $ref: '#/components/schemas/TargetDescriptor' }

    TargetCatalogPatch:
      type: array
      description: >
        Per-launch/sub-project override patch (Phase 3): a patch never forks
        the catalog — unknown target/field ids append, `hidden: true` removes
        an entry, other properties override the base by key.
      items:
        type: object
        properties:
          id:
            {
              type: string,
              description: Addresses the base target; unknown ids append a new target.,
            }
          label: { type: string }
          aliases:
            {
              type: array,
              items: { type: string },
              description: Replaces the base aliases wholesale when present.,
            }
          hidden:
            { type: boolean, description: 'true removes the whole target from the merged catalog.' }
          fields:
            type: array
            items:
              type: object
              properties:
                key:
                  {
                    type: string,
                    description: Addresses the base field; unknown keys append a new field.,
                  }
                label: { type: string }
                hidden: { type: boolean }
              additionalProperties: true
              required: [key]
        required: [id]

    TargetCatalogSummary:
      type: object
      properties:
        catalogKey: { type: string }
        publishedVersion: { type: [integer, 'null'] }
        hasDraft: { type: boolean }
        updatedAt: { type: [string, 'null'], format: date-time }
        drift:
          type: [object, 'null']
          additionalProperties: true
          description: >
            Deterministic diff vs the last scanned spec
            (checkedAt/newFields/removedFields/changedTypes/newTargets), or
            null when the catalog has never been scanned.
      required: [catalogKey, publishedVersion, hasDraft, updatedAt, drift]

    CatalogVersionSummary:
      type: object
      description: One entry in a catalog's version history (Phase 3).
      properties:
        version: { type: integer }
        status:
          {
            type: string,
            enum: [published, archived],
            description: "'published' = the live version; 'archived' = restorable history.",
          }
        publishedAt: { type: [string, 'null'], format: date-time }
        targets: { type: integer }
        fields: { type: integer }
      required: [version, status, publishedAt, targets, fields]

    PublishedCatalogPayload:
      type: object
      properties:
        catalogKey: { type: string }
        version: { type: integer }
        catalog: { $ref: '#/components/schemas/TargetCatalog' }
        project:
          { type: string, description: Present when a sub-project override was applied (Phase 3). }
        overrideVersion: { type: integer }
      required: [catalogKey, version, catalog]

    CatalogPreset:
      type: object
      description: One ready-made catalog a tenant can adopt as their own.
      properties:
        id: { type: string, description: Stable preset id — wire value of the adopt endpoint. }
        label: { type: string }
        description: { type: string }
        suggestedKey:
          { type: string, description: Default catalog key on adopt (callers may override). }
        catalog: { $ref: '#/components/schemas/TargetCatalog' }
      required: [id, label, description, suggestedKey, catalog]

    AdoptPresetPayload:
      type: object
      properties:
        presetId: { type: string }
        catalogKey: { type: string }
        created:
          type: boolean
          description: >
            False when the key already existed — the tenant's catalog is left
            untouched; `version` is then the existing published version (null
            while only a draft exists).
        version: { type: [integer, 'null'] }
      required: [presetId, catalogKey, created, version]

    ScanReport:
      type: object
      description: >
        Result of an AI/deterministic spec scan (plan §6). Lands as the
        catalog's draft, never published.
      properties:
        format:
          type: string
          enum: [docs, openapi, swagger2, graphql-introspection, json-schema, sample, freeform]
        specHash: { type: string }
        cacheHit: { type: boolean }
        targets: { type: integer }
        fields: { type: integer }
        excluded:
          type: array
          description: Technical/config entities the curator dropped, with a reason.
          items:
            type: object
            properties:
              id: { type: string }
              reason: { type: string }
            required: [id, reason]
        fieldMeta: { type: [object, 'null'], additionalProperties: true }
        ai:
          type: object
          properties:
            used: { type: boolean }
            model: { type: [string, 'null'] }
            reason: { type: [string, 'null'] }
          required: [used, model, reason]
      required: [format, specHash, cacheHit, targets, fields, excluded, fieldMeta, ai]

    AiSettingsPolicy:
      type: object
      description: >
        Trust posture + operational limits (AI_SETTINGS_DB_PLAN §7).
        Deployment scope only — tenants can never lift their own guardrails.
      properties:
        modeCeiling: { type: string, enum: [deterministic, ai, hybrid] }
        modeCeilingExtraction:
          oneOf:
            - { type: string, enum: [deterministic, ai, hybrid] }
            - { type: 'null' }
          description: Extraction ceiling override; null = follow `modeCeiling`.
        modeCeilingTransform:
          oneOf:
            - { type: string, enum: [deterministic, ai, hybrid] }
            - { type: 'null' }
          description: 'AI-cleaning ceiling; null = deterministic (off until the operator raises it).'
        shadow: { type: boolean, description: Observe-only mode. }
        promotionForce: { type: boolean, description: Skip the earned-promotion gate. }
        previewRuns: { type: integer, minimum: 0 }
        timeoutMs: { type: integer, minimum: 1 }
        tokenMonthlyLimit: { type: integer, minimum: 0, description: 0 = unlimited. }
        platformConnection:
          type: boolean
          description: True when this deployment's AI connection is billed by the platform operator (gates `limits.aiRunsMonthly`).
        semanticMode:
          oneOf:
            - { type: string, enum: ['off', shadow, live] }
            - { type: 'null' }
          description: >
            Semantic assist deployment mode; null = shadow (observation-only
            default). 'off' also disables the shadow pass; 'live' still
            requires the tenant license feature `semanticAssist`.
      required:
        [
          modeCeiling,
          modeCeilingExtraction,
          modeCeilingTransform,
          shadow,
          promotionForce,
          previewRuns,
          timeoutMs,
          tokenMonthlyLimit,
          platformConnection,
          semanticMode,
        ]

    AiSettingsRead:
      type: object
      description: >
        GET/PUT /import/v1/ai/settings response. The provider key is reduced
        to a masked hint ("…a1b2") or null — the raw key never round-trips.
      properties:
        scope: { type: string }
        version: { type: integer, minimum: 0 }
        connection:
          oneOf:
            - type: object
              properties:
                provider: { type: string, enum: [openai-compat, anthropic] }
                endpoint:
                  {
                    type: [string, 'null'],
                    description: Required for openai-compat; optional override for anthropic.,
                  }
                model: { type: string }
                models:
                  {
                    type: array,
                    items: { type: string },
                    description: Switcher allowlist offered on top of the default model.,
                  }
                extraBody: { type: [object, 'null'], additionalProperties: true }
                apiKeyHint: { type: [string, 'null'] }
              required: [provider, endpoint, model, models, extraBody, apiKeyHint]
            - { type: 'null' }
        policy:
          oneOf:
            - { $ref: '#/components/schemas/AiSettingsPolicy' }
            - { type: 'null' }
        updatedAt: { type: [string, 'null'], format: date-time }
      required: [scope, version, connection, policy, updatedAt]

    AiStatusPayload:
      type: object
      description: >
        `GET /ai/status` — the operator trust artifact (ADR 0006 §7). Never
        carries secrets.
      properties:
        configured:
          {
            type: boolean,
            description: True when a provider + model are configured on this deployment.,
          }
        modes:
          type: object
          properties:
            mapping: { type: string, enum: [deterministic, ai, hybrid] }
            transform: { type: string, enum: [deterministic, ai, hybrid] }
            extraction: { type: string, enum: [deterministic, ai, hybrid] }
          required: [mapping, transform, extraction]
        entitlement:
          type: object
          properties:
            aiAssist: { type: boolean }
            byoModel: { type: boolean }
            cleaningAssistant: { type: boolean }
          required: [aiAssist, byoModel, cleaningAssistant]
        shadow:
          type: object
          properties:
            configured: { type: boolean }
            effective:
              { type: boolean, description: What a mapping run would do right now for this tenant. }
            reason: { type: [string, 'null'], enum: [configured, promotion_gate, null] }
          required: [configured, effective, reason]
        provider:
          oneOf:
            - type: object
              properties:
                id: { type: string }
                model: { type: string }
                endpointHost:
                  {
                    type: [string, 'null'],
                    description: Endpoint host only — never the full URL,
                    path,
                    or key.,
                  }
              required: [id, model, endpointHost]
            - { type: 'null' }
        promptVersion: { type: [string, 'null'] }
        thresholds:
          type: object
          properties:
            aiThreshold: { type: number }
            overrideMargin: { type: number }
          required: [aiThreshold, overrideMargin]
        budget:
          type: object
          properties:
            period: { type: string }
            monthlyLimit: { type: integer, minimum: 0 }
            used: { type: integer, minimum: 0 }
          required: [period, monthlyLimit, used]
        promotionGate:
          type: object
          description: The evidence-based gate — AI leaves shadow only when trust is measured.
          properties:
            minDecisions: { type: integer, minimum: 0 }
            minAgreement: { type: number, minimum: 0, maximum: 1 }
            minHumanDecisions: { type: integer, minimum: 0 }
            force: { type: boolean }
            current:
              type: object
              properties:
                decisions: { type: integer, minimum: 0 }
                agreementRate: { type: [number, 'null'], minimum: 0, maximum: 1 }
                humanDecisions: { type: integer, minimum: 0 }
                humanAgreementRate: { type: [number, 'null'], minimum: 0, maximum: 1 }
                basis: { type: string, enum: [human, proxy] }
              required: [decisions, agreementRate, humanDecisions, humanAgreementRate, basis]
            passed: { type: boolean }
            preview:
              type: object
              description: Bounded "AI preview" window so a fresh tenant sees live judging from import #1.
              properties:
                limit: { type: integer, minimum: 0 }
                used: { type: integer, minimum: 0 }
                remaining: { type: integer, minimum: 0 }
                active: { type: boolean }
              required: [limit, used, remaining, active]
          required: [minDecisions, minAgreement, minHumanDecisions, force, current, passed, preview]
        lastRun:
          oneOf:
            - type: object
              properties:
                runId: { type: string }
                at: { type: string, format: date-time }
                status:
                  { type: string, enum: [ok, shadow, skipped, budget_exceeded, timeout, error] }
                agreementRate: { type: [number, 'null'], minimum: 0, maximum: 1 }
              required: [runId, at, status, agreementRate]
            - { type: 'null' }
      required:
        [
          configured,
          modes,
          entitlement,
          shadow,
          provider,
          promptVersion,
          thresholds,
          budget,
          promotionGate,
          lastRun,
        ]

    AiPreferencesPayload:
      type: object
      description: >
        `GET/PATCH /ai/preferences` — tenant-facing AI assist panel (wizard).
        Merges saved mode, deployment ceiling, license, and live status into
        one round-trip payload.
      properties:
        mapping: { type: string, enum: [deterministic, ai, hybrid] }
        transform:
          {
            type: string,
            enum: [deterministic, ai, hybrid],
            description: Transform-stage (AI cleaning) preference.,
          }
        effective:
          type: object
          properties:
            mapping: { type: string, enum: [deterministic, ai, hybrid] }
            transform: { type: string, enum: [deterministic, ai, hybrid] }
          required: [mapping, transform]
        limits:
          type: object
          properties:
            deploymentCeiling: { type: string, enum: [deterministic, ai, hybrid] }
            transformCeiling: { type: string, enum: [deterministic, ai, hybrid] }
          required: [deploymentCeiling, transformCeiling]
        selectable: { type: array, items: { type: string, enum: [deterministic, ai, hybrid] } }
        selectableTransform:
          { type: array, items: { type: string, enum: [deterministic, ai, hybrid] } }
        model:
          {
            type: [string, 'null'],
            description: Tenant's saved model choice; null = deployment default.,
          }
        models:
          { type: array, items: { type: string }, description: Operator-approved model allowlist. }
        entitlement:
          type: object
          properties:
            aiAssist: { type: boolean }
            byoModel: { type: boolean }
            cleaningAssistant: { type: boolean }
          required: [aiAssist, byoModel, cleaningAssistant]
        configured: { type: boolean }
        provider:
          oneOf:
            - type: object
              properties:
                id: { type: string }
                model: { type: string }
                endpointHost: { type: [string, 'null'] }
              required: [id, model, endpointHost]
            - { type: 'null' }
        shadow:
          type: object
          properties:
            configured: { type: boolean }
            effective: { type: boolean }
            reason: { type: [string, 'null'], enum: [configured, promotion_gate, null] }
          required: [configured, effective, reason]
        promotionGate:
          type: object
          properties:
            preview:
              type: object
              properties:
                limit: { type: integer, minimum: 0 }
                used: { type: integer, minimum: 0 }
                remaining: { type: integer, minimum: 0 }
                active: { type: boolean }
              required: [limit, used, remaining, active]
            passed: { type: boolean }
          required: [preview, passed]
        budget:
          type: object
          properties:
            period: { type: string }
            monthlyLimit: { type: integer, minimum: 0 }
            used: { type: integer, minimum: 0 }
          required: [period, monthlyLimit, used]
        runs:
          oneOf:
            - type: object
              description: Plan quota of AI-assisted runs (hosted-cloud pricing unit). Null when uncapped (BYO/self-host or unlimited license).
              properties:
                period: { type: string }
                monthlyLimit: { type: integer, minimum: 1 }
                used: { type: integer, minimum: 0 }
                remaining: { type: integer, minimum: 0 }
              required: [period, monthlyLimit, used, remaining]
            - { type: 'null' }
      required:
        [
          mapping,
          transform,
          effective,
          limits,
          selectable,
          selectableTransform,
          model,
          models,
          entitlement,
          configured,
          provider,
          shadow,
          promotionGate,
          budget,
          runs,
        ]

    AiProviderHealth:
      type: object
      description: >
        `GET /ai/health` — operator connectivity probe. `reachable` = the
        endpoint actually answered (a 429/401 still counts); only
        DNS/connection/timeout failures are unreachable. Never carries
        secrets.
      properties:
        configured: { type: boolean }
        provider:
          oneOf:
            - type: object
              properties:
                id: { type: string }
                model: { type: string }
                endpointHost: { type: [string, 'null'] }
              required: [id, model, endpointHost]
            - { type: 'null' }
        reachable: { type: boolean }
        classification:
          type: string
          enum:
            [
              ok,
              not_configured,
              rate_limited,
              auth_error,
              not_found,
              timeout,
              unreachable,
              http_error,
            ]
        httpStatus:
          {
            type: [integer, 'null'],
            description: HTTP status when the endpoint answered with one; null for network/timeout.,
          }
        detail:
          {
            type: [string, 'null'],
            description: Provider's own (truncated) error text — the actionable diagnostic.,
          }
        latencyMs: { type: [integer, 'null'] }
        checkedAt: { type: string, format: date-time }
      required:
        [configured, provider, reachable, classification, httpStatus, detail, latencyMs, checkedAt]

    ExtractFactsPayload:
      type: object
      description: Fact-map v2 Phase 1 extraction outcome. Soft failures are statuses, not HTTP errors.
      properties:
        status:
          type: string
          enum: [ok, skipped, unsupported, rate_limited, budget_exceeded, timeout, error]
        reason: { type: [string, 'null'] }
        facts:
          type: array
          items: { $ref: '#/components/schemas/UnpGroundedFact' }
        ungrounded:
          type: array
          description: Semantic facts whose quote could not be located in the document text — never shown as highlights.
          items:
            type: object
            properties:
              targetKey: { type: [string, 'null'] }
              label: { type: string }
              value: { type: string }
              normalizedValue: { type: [string, 'null'] }
              quote: { type: string }
              confidence: { type: number, minimum: 0, maximum: 100 }
            required: [targetKey, label, value, quote, confidence]
        ai:
          oneOf:
            - type: object
              properties:
                runId: { type: [string, 'null'] }
                schemaFingerprint: { type: [string, 'null'] }
                model: { type: [string, 'null'] }
                promptVersion: { type: string }
                cacheHit: { type: boolean }
                documentType: { type: [string, 'null'] }
                dropped: { type: integer }
                normalizedRejected:
                  type: integer
                  description: Canonical forms rejected by the plausibility guard (verbatim value applies).
                truncated:
                  {
                    type: boolean,
                    description: The document exceeded the extraction window — the model read only the beginning.,
                  }
                tokens:
                  oneOf:
                    - type: object
                      properties:
                        input: { type: integer }
                        output: { type: integer }
                      required: [input, output]
                    - { type: 'null' }
                latencyMs: { type: [integer, 'null'] }
              required:
                [
                  runId,
                  schemaFingerprint,
                  model,
                  promptVersion,
                  cacheHit,
                  documentType,
                  dropped,
                  normalizedRejected,
                  truncated,
                  tokens,
                  latencyMs,
                ]
            - { type: 'null' }
      required: [status, reason, facts, ungrounded, ai]

    UnpGroundedFact:
      type: object
      description: A deterministic or AI-extracted fact grounded to a verbatim document span (PDF highlight overlay).
      properties:
        kind: { type: string, enum: [email, iban, date, amount, semantic] }
        value: { type: string, description: Matched substring or normalized value for display. }
        spanStart: { type: integer, minimum: 0 }
        spanEnd: { type: integer, minimum: 1 }
        boxes:
          type: array
          items:
            type: object
            properties:
              page: { type: integer, minimum: 0, description: 0-based page index. }
              x0: { type: number }
              y0: { type: number }
              x1: { type: number }
              y1: { type: number }
            required: [page, x0, y0, x1, y1]
        currency: { type: string, minLength: 3, maxLength: 3, description: Amount facts only. }
        minorUnits: { type: integer, description: Amount facts only. }
        label: { type: string, description: Document's own wording for the fact (semantic facts). }
        targetKey:
          {
            type: string,
            description: Host columnSchema key the extractor proposed (semantic facts).,
          }
        confidence:
          {
            type: number,
            minimum: 0,
            maximum: 100,
            description: Extractor confidence (semantic facts).,
          }
        source:
          {
            type: string,
            enum: [deterministic, ai],
            description: Omitted = deterministic (legacy).,
          }
        quote:
          {
            type: string,
            description: Verbatim source quote the span was grounded from (semantic facts).,
          }
        normalizedValue:
          {
            type: string,
            description: Canonicalized value (ISO date,
            decimal point) when the extractor offers one.,
          }
      required: [kind, value, spanStart, spanEnd, boxes]
