openapi: 3.0.3
info:
  title: Identiwise Identity Verification API
  description: |
    A multi-tenant, microservice-based REST API for AI-powered identity verification.

    **Authentication:**
    - **System Users & API Integrations:** Use `Authorization: Bearer <token>`
    - **End-User SDK (Subjects):** Use `X-Api-Key: <ephemeral_token>`

    **Tenancy:**
    - Tenancy is determined automatically via the `Host` header of the request.
  version: 1.0.0
  contact:
    name: Identiwise
    email: hello@identiwise.com

servers:
  - url: https://{tenant}.identiwise.com/api/v1
    description: Production Tenant API
    variables:
      tenant:
        default: acme
        description: Enter your unique tenant subdomain (e.g., 'acme', 'companyx').

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: >-
        Opaque long-lived token for system users and back-end integrations — an API
        token from POST /admin/api-tokens, or the session token returned by
        POST /admin/login. It is not a JWT and carries no readable claims: the server stores
        a SHA-256 fingerprint to find the row and a bcrypt hash to verify it. Revocable,
        with an optional expiry.
    EphemeralAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Short-lived token for end-user subjects.

  schemas:
    # --- Common Models ---
    Error:
      type: object
      properties:
        error:
          type: string
        details:
          type: string

    SystemUser:
      type: object
      properties:
        id:
          type: integer
        username:
          type: string
        email:
          type: string
          format: email
        role:
          type: string
          enum: [superuser, admin, view]
        status:
          type: string
          enum: [active, suspended]

    ApiToken:
      type: object
      properties:
        id:
          type: integer
        description:
          type: string
        expires_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time

    Subject:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: "UUIDv7 string"
        external_uid:
          type: string
        first_name:
          type: string
        last_name:
          type: string
        email:
          type: string
        status:
          type: object
          description: Included if ?include=status is used
          properties:
            license_status:
              type: string
            selfie_status:
              type: string

    # --- Review & Workflow Models ---
    ReviewAction:
      type: object
      required:
        - type
        - id
        - action
      properties:
        type:
          type: string
          enum: [license, selfie]
        id:
          type: string
          format: uuid
          description: "license/selfie UUIDv7 string"
        action:
          type: string
          enum: [approve, reject, use, comment]
        notes:
          type: string
        require_reupload:
          type: boolean
        require_subject_reply:
          type: boolean

    # --- Ephemeral / Config Models ---
    EphemeralTokenRequest:
      type: object
      properties:
        subject_id:
          type: string
          format: uuid
          description: "UUIDv7 string"
        expires_in_minutes:
          type: integer
          default: 60
        max_uses:
          type: integer
          nullable: true
          minimum: 1
          description: "How many upload/reupload submissions this token allows. Reads never consume a use. null/absent = unlimited until expires_at; an exhausted token is fully dead (all endpoints deny). 1 = single-use."
        one_time:
          type: boolean
          deprecated: true
          description: "Legacy alias: true maps to max_uses: 1 (ignored when max_uses is present). Historically this flag made the token born-dead — rejected on its first use."
        preset_id:
          type: string
          format: uuid
          description: "Optional: Load settings from a saved preset (UUID string)"
        workflow_id:
          type: string
          format: uuid
          description: "Optional: Bind token to a specific workflow (UUID string)"
        # Configuration Overrides
        upload_type:
          type: string
          enum: [any, upload, reupload, none]
        verification_type:
          type: string
          enum: [license, selfie, age_gender_emotion]
        can_edit_details:
          type: boolean
        require_face_match:
          type: boolean
          default: false
          description: >
            Selects the token's assurance semantics for licence approvals.
            false (default) = document-check flow: a licence whose subject has no APPROVED
            reference selfie may auto-approve on document detection + OCR alone — the
            approval then asserts "the document reads correctly", not "the presenter's
            identity was verified" (identity is established by the later selfie-match
            events, as in the course flow). true = strict flow: a licence approval REQUIRES
            a face match against an approved reference; with no reference on file the
            submission parks for admin review (OCR still runs, so the reviewer sees the
            score) — one admin approval bootstraps the subject, after which automation
            resumes with the anchor. Inherited from the preset when minted with preset_id
            (an explicit null here means "no opinion" — the preset's value wins; it never
            downgrades a strict template). Note the strict park still consumes a token use
            (a use is reserved before any pipeline cost), so a max_uses 1 token is
            exhausted by the parked submission. Strictness is PER TOKEN: a tenant that
            also mints document-check tokens for the same subject re-opens the
            document-only anchor for that subject's chain — mint strict-only for subjects
            that need identity assurance.
        min_age:
          type: integer
          description: "For age verification flows"

    # Overloaded schema for the Ephemeral Controller
    # It handles Token Gen, Workflow CRUD, and Preset CRUD
    EphemeralControllerPayload:
      oneOf:
        - $ref: '#/components/schemas/EphemeralTokenRequest'
        - type: object
          description: "Manage Workflows"
          properties:
            create_workflow: { type: boolean }
            list_workflows: { type: boolean }
            update_workflow_id: { type: string, format: uuid }
            delete_workflow_id: { type: string, format: uuid }
            workflow_name: { type: string }
            steps: { type: array, items: { type: object } }
        - type: object
          description: "Manage Presets"
          properties:
            create_preset: { type: boolean }
            list_presets: { type: boolean }
            update_preset_id: { type: string, format: uuid }
            delete_preset_id: { type: string, format: uuid }
            preset_name: { type: string }
            require_face_match: { type: boolean, description: "template for the minted tokens' assurance semantics — see EphemeralTokenRequest.require_face_match" }

    # --- AI Response Models ---
    VerificationResult:
      type: object
      properties:
        status:
          type: string
          description: >
            The pipeline verdict (LICENSE_APPROVED / LICENSE_PENDING_APPROVAL / ID_NOT_MATCHED /
            NO_FACE_DETECTED / SELFIE_APPROVED / SELFIE_PENDING_APPROVAL / …) — or
            ATTEMPT_SUPERSEDED: while the AI was processing this attempt an administrator
            approved / rejected / deleted the submission, or a newer attempt of the same
            submission (same verification_id / external_uid) replaced it; whatever decided or
            superseded it stands and this attempt's automated result was discarded (read the
            submission's current state through the admin API). The upload itself was recorded
            and metered before processing.
        score:
          type: integer
        similarity:
          type: number
          format: float
        reason:
          type: string
          description: >
            Present on a *_PENDING_APPROVAL verdict that was parked for a stated reason —
            "Multiple faces detected.", "Internal face mismatch on ID.", and the
            reference-side outcomes "Reference image unavailable." (the stored reference could
            not be retrieved from the object store), "Reference image has no detectable face."
            and "Reference face check unavailable." — an infrastructure failure or a face-less
            reference never yields an approval; a human reviews the submission.
            The same rule now covers every dependency the pipeline calls, so a
            verification whose AI service was unavailable is parked rather than decided:
            "Document detection unavailable.", "Face detection unavailable.",
            "Face comparison unavailable.", "Internal face comparison unavailable." and
            "Text extraction unavailable.". Treat these exactly like the reference-side
            reasons — the submission is undecided and awaiting a human, NOT rejected. They
            replace the former terminal error statuses,
            and a service outage no longer reports the domain verdicts NO_FACE_DETECTED or
            ID_NOT_MATCHED.
            A token minted with require_face_match adds one POLICY reason —
            "This token requires a face match for approval and no approved reference selfie
            is on file - an administrator must review this submission." — nothing is down;
            the token's strict flow demands an identity anchor that does not exist yet, and
            score still carries the OCR evidence for the reviewer.
        face_comparison:
          type: string
          enum: [matched, none]
          description: >
            Present on every pipeline LICENSE_APPROVED: the approval's assurance
            level, machine-readable. "matched" = a face comparison against an approved
            reference selfie ran and passed on THIS attempt. "none" = document-check
            approval — no reference existed and no face comparison ran; the approval asserts
            only that the document was detected and its text matches the subject's stored
            details. "matched" asserts that the comparison ran — NOT how the reference
            itself was approved: the chain is only as strong as its root, and a subject
            whose reference selfie was auto-approved against a document-check licence
            carries a document-only root (mint strict-only tokens for a subject to keep the
            root a human approval). An integration that needs identity assurance must either
            read this field, rely on the selfie-match events, or mint tokens with
            require_face_match. The same value is persisted on the submission row and
            describes only the STANDING pipeline approval: a new attempt, an admin
            rejection, or a manual admin approval each reset it to NULL (NULL = legacy
            row, no standing pipeline approval, or a manual admin approval — the human is
            the anchor there).
        age_estimate:
          type: integer
        age_verified:
          type: boolean

security:
  - BearerAuth: []

paths:
  # ===========================================
  # ADMIN / INTEGRATION ROUTES (Bearer Auth)
  # ===========================================

  /admin/login:
    post:
      summary: Admin Login
      description: >
        Authenticates a system user. Users WITHOUT 2FA enrolled receive a bearer token
        directly. Users WITH 2FA enrolled receive a short-lived single-use challenge
        instead and must complete POST /admin/login/verify. In tenants with
        enforce_2fa enabled, non-enrolled users receive 403 until they enrol via the
        account portal.
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
      responses:
        200:
          description: >
            Login step successful — either a bearer token (no 2FA) or a TOTP challenge
            (2FA enrolled; no token yet).
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    title: Bearer issued (no 2FA)
                    properties:
                      token: { type: string }
                      username: { type: string }
                      role: { type: string }
                  - type: object
                    title: TOTP challenge (2FA enrolled)
                    properties:
                      challenge: { type: string, enum: [totp] }
                      challenge_token: { type: string, description: "Single-use, 5-minute expiry, max 5 code attempts" }
                      expires_in: { type: integer, example: 300 }
        403:
          description: 2FA enrolment required (tenant enforces 2FA; enrol via the account portal)

  /admin/login/verify:
    post:
      summary: Complete 2FA Login
      description: >
        Second phase of the interactive login. Exchanges the challenge from
        /admin/login plus a 6-digit TOTP code for the bearer token. Every failure mode
        (unknown/expired/consumed challenge, wrong code, attempts exhausted, wrong
        tenant) returns one uniform 401.
      tags: [Auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [challenge_token, code]
              properties:
                challenge_token: { type: string }
                code: { type: string, example: "123456" }
      responses:
        200:
          description: Bearer token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  username: { type: string }
                  role: { type: string }
        401:
          description: Invalid or expired challenge

  /admin/api-tokens:
    get:
      summary: List API Tokens
      description: List long-lived API tokens for server-to-server integration (Superuser only).
      tags: [System Admin]
      responses:
        200:
          description: List of tokens retrieved successfully
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ApiToken'
    post:
      summary: Create API Token
      description: Generate a new long-lived API token (Superuser only).
      tags: [System Admin]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                description: { type: string, maxLength: 255 }
                expires_at:
                  type: string
                  format: date-time
                  description: "accepted forms are 'YYYY-MM-DD HH:MM:SS', RFC 3339 'YYYY-MM-DDTHH:MM:SSZ' (numeric offsets are rejected), or 'YYYY-MM-DD' (normalized to midnight). Absent or empty = no expiry."
      responses:
        201:
          description: Token created (displayed once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
        400:
          description: "malformed JSON body, or expires_at/description invalid"

  /admin/api-tokens/{id}:
    delete:
      summary: Revoke API Token
      tags: [System Admin]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        200: { description: Token revoked }

  /admin/users:
    get:
      summary: List System Users
      tags: [System Admin]
      parameters:
        - name: role
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string }
      responses:
        200:
          description: List of users retrieved successfully
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SystemUser'
        400:
          description: "invalid ?since= (must be YYYY-MM-DD)"
    post:
      summary: Create System User
      tags: [System Admin]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [username, email, password]
              properties:
                username: { type: string }
                email: { type: string }
                password: { type: string }
                role: { type: string, enum: [admin, superuser, view] }
      responses:
        201: { description: User created }
        400: { description: "Missing or non-string username/email/password, or invalid role" }
        409: { description: "a user with this email already exists (system_users.email is platform-unique; the body does not distinguish own-tenant from cross-tenant collisions)" }

  /admin/users/{id}:
    get:
      summary: Get System User details
      tags: [System Admin]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        200:
          description: User details retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SystemUser'
    patch:
      summary: Update System User
      tags: [System Admin]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                role: { type: string }
                status: { type: string }
                email: { type: string }
      responses:
        200: { description: Updated }
        400: { description: "Non-string username/email, or invalid role/status" }
        409: { description: "a user with this email already exists (same oracle-free body as POST /admin/users)" }
    delete:
      summary: Delete System User
      tags: [System Admin]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        200: { description: Deleted }

  /admin/subjects:
    get:
      summary: List Subjects
      description: Retrieve subjects. Use `includeDeletionRequests=1` to see the Privacy Queue.
      tags: [Subjects]
      parameters:
        - name: offset
          in: query
          schema: { type: integer }
        - name: limit
          in: query
          schema: { type: integer }
        - name: includeDeletionRequests
          in: query
          schema: { type: boolean }
          description: "If true, response includes pending GDPR deletion requests."
        - name: deletionRequestStatus
          in: query
          schema: { type: string, enum: [pending, completed] }
      responses:
        200:
          description: List of subjects retrieved successfully
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Subject'
    post:
      summary: Create Subject
      tags: [Subjects]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [first_name, last_name]
              properties:
                external_uid: { type: string }
                first_name: { type: string }
                last_name: { type: string }
                email: { type: string }
      responses:
        201: { description: Subject created }

  /admin/subjects/{id}:
    get:
      summary: Get Subject
      tags: [Subjects]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: include
          in: query
          schema: { type: string, enum: [status] }
      responses:
        200:
          description: Subject details retrieved successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Subject'
    patch:
      summary: Update Subject
      tags: [Subjects]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: { type: string }
                last_name: { type: string }
                external_uid: { type: string }
      responses:
        200: { description: Updated }
        404: { description: "Subject not found in this tenant - including when a concurrent erasure removed it while the update was in flight (the write is rolled back, never partially applied)" }
        503: { description: "Temporary database contention - the update was not applied; retry" }
    delete:
      summary: Delete Subject (Immediate)
      description: Permanently removes subject and all related S3 data. Note that `requestBody` is forbidden for DELETE in OpenAPI 3.0, so the reason must be passed as a query parameter.
      tags: [Subjects]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: reason
          in: query
          schema: { type: string }
          description: "Optional reason for deletion logging."
      responses:
        200: { description: Deleted }

  /admin/subjects/{id}/request-deletion:
    post:
      summary: Admin-Initiated Deletion Request
      description: Adds the subject to the deletion queue instead of deleting immediately.
      tags: [Subjects]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 4096 }
                confirmation: { type: boolean, default: true }
      responses:
        201: { description: "Request queued; the body carries request_id" }
        200: { description: A request for this subject was already outstanding - one pending erasure request per subject; the response carries that request's id }
        400: { description: "reason is not a string, or exceeds 4096 bytes" }
        404: { description: Subject not found in this tenant - including when a concurrent erasure removed it while the request was being recorded }

  /admin/review:
    get:
      summary: Get Review Items
      description: Fetch items pending manual review, or details for a specific subject.
      tags: [Review]
      parameters:
        - name: pending
          in: query
          schema: { type: boolean }
          description: "If true, returns list of all pending items."
        - name: subjectId
          in: query
          schema: { type: integer }
          description: "Fetch full review history for specific subject."
        - name: externalUid
          in: query
          schema: { type: string }
      responses:
        200:
          description: Review data retrieved successfully
          content:
            application/json:
              schema:
                type: object
        400:
          description: "invalid ?since=; non-string query params (subjectId, externalUid, type, id, identifier, value); subjectId+externalUid together"
    post:
      summary: Submit Review Action
      description: Approve, Reject, or Comment on a submission.
      tags: [Review]
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReviewAction'
      responses:
        200: { description: Action recorded }

  /admin/photos/{type}/{id}:
    get:
      summary: View Private Photo
      description: Returns a 302 Redirect to a secure, pre-signed S3 URL.
      tags: [Review]
      parameters:
        - name: type
          in: path
          required: true
          schema: { type: string, enum: [license, selfie] }
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: photo
          in: query
          description: "Fetch 'previous' or 'original' if history exists"
          schema: { type: string }
      responses:
        302:
          description: Redirects to S3
        404:
          description: >
            The submission does not exist for this tenant, or its row holds no object key
            at all (a privacy erasure clears it). This means the photo is genuinely not
            there — it is never returned because the object store was unreachable.
        503:
          description: >
            Two causes, both retryable (honour `Retry-After`). The link could not
            be signed — the object store is misconfigured or unavailable; this used to
            answer the 404 above, which told a reviewer that a real person's identity
            document does not exist (signing does no I/O, so a failure here can never
            mean the object is absent). The access could not be RECORDED — every
            issuance writes an append-only `document.viewed` audit row FIRST, and with
            the audit store unavailable no URL is handed out (fail-closed: an identity
            document is never accessed untraced).

  /admin/submissions:
    get:
      summary: List All Submissions
      description: >-
        Returns submission metadata (row id, subject, state, score, flag, external_uid).
        It does NOT return the stored object key: the
        `file_name` field was removed, because it handed a durable S3 key to every role
        that can read this list, bypassing the audited photo route. To reach the image,
        call GET /admin/photos/{type}/{id}, which records the access and returns a
        15-minute presigned URL.
      tags: [Reporting]
      parameters:
        - name: since
          in: query
          schema: { type: string, format: date }
          description: "YYYY-MM-DD; anything else answers 400"
        - name: limit
          in: query
          schema: { type: integer }
        - name: offset
          in: query
          schema: { type: integer }
      responses:
        200:
          description: List of submissions retrieved successfully
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
        400:
          description: "invalid ?since="

  /admin/submissions/{id}/history:
    get:
      summary: One Submission's Timeline
      description: >-
        The submission's current decision state plus a merged, time-ordered timeline of
        its creation, its decision and every message exchanged about it.
      tags: [Reporting]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: type
          in: query
          required: true
          schema: { type: string, enum: [license, selfie] }
      responses:
        200:
          description: Timeline retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  submission_id: { type: string, format: uuid }
                  submission_type: { type: string, enum: [license, selfie] }
                  subject_id: { type: string, format: uuid }
                  verification_id: { type: string, nullable: true }
                  admin_approval: { type: string, nullable: true, enum: [approved, rejected, null] }
                  review_status: { type: string, nullable: true }
                  flag: { type: integer }
                  admin_notes: { type: string, nullable: true }
                  error_msg: { type: string, nullable: true }
                  timeline:
                    type: array
                    items:
                      type: object
                      properties:
                        event_type: { type: string, enum: [creation, approval, rejection, comment] }
                        timestamp: { type: string, format: date-time }
                        details: { type: string }
                        author_type: { type: string }
                        text: { type: string }
        400:
          description: "?type= is required and must be license or selfie"
        404:
          description: The submission does not exist for this tenant

  /admin/raw:
    get:
      summary: Export Raw Data
      tags: [Reporting]
      parameters:
        - name: table
          in: query
          required: true
          schema: { type: string, enum: [system_users, subjects, usage_records, submissions, api_tokens] }
        - name: format
          in: query
          schema: { type: string, enum: [json, csv] }
      responses:
        200:
          description: Data export retrieved successfully
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
            text/csv:
              schema: { type: string }
        400:
          description: "missing/non-string ?table=, invalid ?since=, non-string ?date="
        503:
          description: >
            The export could not be recorded — every run writes an append-only
            `raw_export.run` audit row BEFORE any row leaves, and with the audit store
            unavailable no data is returned (fail-closed). Honour `Retry-After`.

  /admin/usage:
    get:
      summary: Get Usage Stats
      tags: [Reporting]
      parameters:
        - name: type
          in: query
          required: true
          schema: { type: string, enum: [raw, aggregated] }
        - name: date
          in: query
          description: "Comma separated range: YYYY-MM-DD,YYYY-MM-DD"
          schema: { type: string }
      responses:
        200:
          description: Usage data including Plan Limits if available.

  /admin/test:
    get:
      summary: Authenticated probe — echoes the client IP as the platform sees it
      description: |
        Any authenticated system user. `client_ip` is `REMOTE_ADDR` after `mod_remoteip`:
        behind the ALB (which appends the caller's address to `X-Forwarded-For` and is the
        pod's only trusted peer — `externalTrafficPolicy: Local` + `REMOTEIP_TRUSTED_PROXIES`)
        it is the TRUE caller address — the value the tenant `ip_allow_list` gate compares
        and the one audit/rate-limit features record. `via_trusted_proxy` is true when that
        address was derived from a trusted proxy's `X-Forwarded-For` (it differs from the
        TCP peer); false when the caller connected directly. A client-supplied
        `X-Forwarded-For` never overrides the address the load balancer appended. Diagnostic
        only — the ring-2 smoke asserts both fields through the ALB.
      tags: [Admin]
      responses:
        200:
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  client_ip: { type: string, example: "203.0.113.9" }
                  via_trusted_proxy: { type: boolean, example: true }
        401:
          description: Missing/invalid bearer.
        403:
          description: "`{\"error\":\"Your IP is not allowed\"}` — the tenant's ip_allow_list does not contain `client_ip` (every routed endpoint answers this, not only this one)."

  # ===========================================
  # EPHEMERAL TOKEN & CONFIG (Mixed Auth)
  # ===========================================

  /ephemeral-tokens:
    post:
      summary: Manage Ephemeral Tokens, Workflows, & Presets
      description: >
        **Overloaded Endpoint:**
        1. Send `subject_id` to create a new Token.
        2. Send `create_workflow: true` to define a new workflow.
        3. Send `create_preset: true` to define a new preset.
        4. Send `list_workflows: true` or `list_presets: true` to retrieve configs.
      tags: [Configuration]
      security:
        - BearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EphemeralControllerPayload'
      responses:
        201:
          description: Created (Token, Workflow, or Preset)
        200:
          description: List or Update successful
        400:
          description: "malformed/non-object JSON; any non-scalar top-level body value except `steps` (previously-ignored structured extras now 400); a non-scalar step_name/step_order/config_json inside a steps item; invalid ids/max_uses"

  /{resource}/{id}:
    delete:
      summary: Generic Resource Deletion
      description: "Unified delete endpoint for subjects, licenses, or selfies."
      tags: [System Admin]
      security:
        - BearerAuth: []
      parameters:
        - name: resource
          in: path
          required: true
          schema: { type: string, enum: [subjects, licenses, selfies] }
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        200: { description: Resource deleted }

  # ===========================================
  # SUBJECT / CLIENT SDK ROUTES (X-Api-Key)
  # ===========================================

  /ephemeral/capability:
    get:
      summary: Get Token Capabilities
      description: Returns permissions and workflow steps allowed for this token.
      tags: [Client SDK]
      security:
        - EphemeralAuth: []
      responses:
        200:
          description: Capabilities retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  can_edit_details: { type: boolean }
                  upload_type: { type: string }
                  min_age: { type: integer }
                  require_face_match: { type: boolean, description: "which assurance semantics this token runs under — see EphemeralTokenRequest.require_face_match" }
                  workflow_steps:
                    type: array
                    items:
                      type: object

  /subject/details:
    get:
      summary: Get Subject Details
      tags: [Client SDK]
      security:
        - EphemeralAuth: []
      responses:
        200:
          description: Subject details retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                   first_name: { type: string }
                   license_number: { type: string }
                   addr_street: { type: string }
    post:
      summary: Update Subject Details
      tags: [Client SDK]
      security:
        - EphemeralAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                 first_name: { type: string }
                 license_number: { type: string }
      responses:
        200: { description: Updated }
        404: { description: "Subject not found in this tenant - including when a concurrent erasure removed it while the update was in flight (the write is rolled back, never partially applied)" }
        503: { description: "Temporary database contention - the update was not applied; retry" }

  /subject/upload:
    post:
      summary: Upload Verification Image
      description: >
        Uploads a file for processing.
        Use `type=analyze` for demographic data.
        Use `type=verify_age` to check against `min_age`.
      tags: [Client SDK]
      security:
        - EphemeralAuth: []
      parameters:
        - name: type
          in: query
          required: true
          schema: { type: string, enum: [license, selfie, analyze, verify_age] }
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                image_file:
                  type: string
                  format: binary
                manual:
                  type: integer
                  enum: [0, 1]
                  description: "1 to skip AI and flag for manual review"
      responses:
        201:
          description: Processed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerificationResult'
        409:
          description: >
            The verification_id / external_uid names an already-APPROVED submission of this
            subject: an approved submission is final for the subject; no token use is
            consumed. Re-opening is an administrator action (reject + require_reupload, then
            /subject/reupload). Also answered, after the fact, when the submission changed
            state (was approved) while this upload's storage write was in flight — nothing is
            recorded, the just-written object is removed (the reserved token use is not refunded).
        503:
          description: >
            `type=analyze` / `type=verify_age` only: the AI service could not be
            reached, so NOTHING is asserted about the person in the image — the response
            carries neither `age_estimate` nor `age_verified`, and `status` is
            ANALYSIS_UNAVAILABLE / AGE_VERIFICATION_UNAVAILABLE. The upload itself
            SUCCEEDED: the object is stored, the submission row exists and is named by
            `selfie_id`, and the token use is spent (it is reserved before AI cost and not
            refunded), so a retry is a new attempt rather than a resubmission of a lost
            file. Honour `Retry-After`. These two routes answer 503 rather than parking for
            review because they have no review queue behind them; the licence and selfie
            pipelines park pending with a reason instead (see VerificationResult.reason).
            Also answered when the object store rejected the write — in
            THAT case nothing at all was recorded and the body says so.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  status: { type: string, enum: [ANALYSIS_UNAVAILABLE, AGE_VERIFICATION_UNAVAILABLE] }
                  selfie_id: { type: string, format: uuid }

  /subject/reupload:
    post:
      summary: Re-upload Image
      tags: [Client SDK]
      security:
        - EphemeralAuth: []
      parameters:
        - name: type
          in: query
          required: true
          schema: { type: string, enum: [license, selfie] }
        - name: id
          in: query
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                image_file:
                  type: string
                  format: binary
      responses:
        201: { description: Re-uploaded successfully }
        409: { description: "The submission changed state (approved / no longer flagged) while the storage write was in flight: nothing recorded, object removed; the reserved use is not refunded" }

  /subject/messages:
    get:
      summary: Get Correspondence
      tags: [Client SDK]
      security:
        - EphemeralAuth: []
      parameters:
        - name: filter
          in: query
          schema: { type: string, enum: [reupload, need_reply] }
      responses:
        200: { description: List of messages }
    post:
      summary: Post Reply
      tags: [Client SDK]
      security:
        - EphemeralAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [comment]
              properties:
                comment: { type: string }
                license_id: { type: string, format: uuid }
                selfie_id: { type: string, format: uuid }
      responses:
        201: { description: Message posted }

  /subject/request-deletion:
    post:
      summary: Request Data Deletion (GDPR)
      tags: [Client SDK]
      security:
        - EphemeralAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [confirmation]
              properties:
                reason: { type: string, maxLength: 4096 }
                confirmation: { type: boolean }
      responses:
        201: { description: "Deletion request queued; the body carries request_id" }
        200: { description: A request for this subject was already outstanding - one pending erasure request per subject; the response carries that request's id }
        400: { description: "confirmation missing, or reason is not a string or exceeds 4096 bytes" }
        404: { description: Subject not found in this tenant - including when a concurrent erasure removed it while the request was being recorded }