> ## Documentation Index
> Fetch the complete documentation index at: https://docs.get-rial.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Close a case for batch analysis

> **New in GET-59.** Attaches the claimant's written `narrative` to the verification and emits `verification.case.completed` on the internal EventBridge bus. The case-analysis worker (`rial-platform-worker-staging` only today) picks that event up, fetches every capture associated with the verification, and runs Gemini multi-image cross-reasoning over the batch + narrative. The resulting `CaseAnalysisReport` is logged to CloudWatch and (in a follow-up slice) persisted onto the verification.

**Idempotency:** atomic conditional write — narrative-not-already-set AND status in `{pending, partially_captured`}. Either guard failing returns 409 `already_finalized`. The handler does NOT compare narratives; a retry with the same narrative still 409s. Callers wanting idempotency-on-same-narrative can probe via `GET /v1/verifications/:id` first.

**Publish discipline:** if the bus emit fails, the DB write still succeeded so the request returns 200. Failing the request would force a retry which would then 409 against `attribute_not_exists(narrative)` and leave the operator stuck. Bus failure is logged for ops visibility.

**Production status:** worker is `CASE_ANALYSIS=stub` on prod per ADR-002 sibling deferral — the endpoint accepts the call and the event fires, but no real analysis runs until the first pay-customer onboards `verify.get-rial.com`.



## OpenAPI

````yaml /api-reference/openapi.json post /v1/verifications/{id}/finalize
openapi: 3.1.0
info:
  title: rial-platform — Verifications API
  version: 0.1.0
  description: |-
    # rial-platform — Verifications API

    HTTP control plane for the rial-platform verifications-service:
    creating verifications, capturing media against them, finalising
    cases for batch analysis, and the operator-facing dashboard.

    ## Audience

    - **Tenants** integrating rial into a claims / verification flow —
      use the operator-issued API key and call `POST /v1/verifications`,
      then send the returned `capture_url` to your end-user.
    - **The dashboard SPA** (`app.get-rial.com`) — same surface,
      authenticated via the `rial_session` cookie.
    - **The WhatsApp bot (rialclaw)** — signs requests with HMAC
      (`X-Bot-Auth`), carries the target tenant in the body.
    - **End-user browsers** capturing photos against a fresh
      verification — authenticated by possession of the path `:token`
      alone (the token IS the credential, see ADR-0002).

    ## Auth modes

    - `cookieSession` — `rial_session` cookie set by Google OAuth at
      `/v1/auth/google/callback`. Dashboard browsers.
    - `bearerApiKey` — `Authorization: Bearer pk_live_<…>` API key,
      tenant-scoped. Server-to-server integrations.
    - `captureToken` — possession of the verification id (`vfy_…`)
      in the URL path authenticates the SPA capture flow. Same trust
      model as Stripe payment intent client_secret.
    - `botHmac` — `X-Bot-Auth: <hmac>` from the WhatsApp bot.

    ## State machine

    A verification's status: `pending` → `partially_captured` →
    `completed` | `expired` | `failed`. State transitions emit
    domain events on the internal EventBridge bus; webhook events
    are translated from those and delivered to the tenant's
    configured `webhook_url` (HMAC-signed via the per-tenant
    `webhook-signing` secret). `pending`/`partially_captured` can
    also side-branch to `abandoned` — reported by the capture screen
    via `POST /v1/verifications/:token/progress` when the end user
    leaves before finishing. It is not terminal: a later capture
    resurrects the row to `partially_captured` like any other, and no
    webhook event fires for the transition.

    ## Conventions

    - Wire format is **snake_case JSON** in both directions; the
      service maps to camelCase internally.
    - Errors are `{error: {code, message, fields?}}` — `code` is
      the stable string SDKs branch on; `message` is human-readable
      English; `fields` is present only on `invalid_request`
      validation failures.
    - IDs are ULIDs with type prefixes: `vfy_`, `cap_`, `tnt_`,
      `walotp_`, `mlk_`.
    - Pagination is opaque-cursor: client passes back whatever
      `next_cursor` the previous page returned; absent means done.

    ## Out of scope here

    - Webhook payloads delivered to tenant URLs — those are
      documented separately under `docs/webhook-events.md`. This
      spec covers only the inbound HTTP surface, not the outbound
      HTTP rial → tenant calls.
    - Internal EventBridge events on the `rial-platform-events` bus
      (`verification.created`, `verification.case.completed`, …) —
      consumer-internal, not part of the public contract.
  contact:
    name: rial-platform team
    url: https://github.com/Rial-ventures-Inc/rial-platform
  license:
    name: UNLICENSED — proprietary, internal use only
servers:
  - url: https://platform-staging.get-rial.com
    description: Staging (safe to hit; ANALYSIS=stub, CASE_ANALYSIS=gemini-case)
  - url: https://platform.get-rial.com
    description: Production (live tenant traffic; do not test against)
security: []
tags:
  - name: Verifications
    description: >-
      Create, fetch, list, and finalize verifications. The core of the API —
      every tenant integration starts here.
  - name: Captures
    description: >-
      Upload media against an existing verification. Two-stage flow: mint a
      presigned S3 URL → PUT bytes direct to S3 → POST submit-capture with the
      resulting key. Hardware attestation (Cloudflare Turnstile / Apple PAT) is
      verified per-capture.
  - name: Case Analysis
    description: >-
      Batch multi-image case analysis (GET-59). Finalize a verification with a
      claimant narrative; the worker runs Gemini multi-image cross-reasoning and
      emits a structured CaseAnalysisReport. Staging-only today per ADR-002
      sibling deferral.
  - name: Public Trial
    description: >-
      Unauthenticated trial endpoint behind `get-rial.com/trial`. Each call
      mints a fresh verification under the rial. tenant tagged
      `originChannel="wa"`, so the WA-completion worker delivers the verdict to
      the configured trial number.
  - name: Templates
    description: >-
      Publish, update, revoke and inspect link templates — the reusable capture
      links behind `/l/{org}/{slug}`. Callable with a secret key; each publish
      snapshots a versioned capture spec (GET-83).
paths:
  /v1/verifications/{id}/finalize:
    post:
      tags:
        - Case Analysis
      summary: Close a case for batch analysis
      description: >-
        **New in GET-59.** Attaches the claimant's written `narrative` to the
        verification and emits `verification.case.completed` on the internal
        EventBridge bus. The case-analysis worker
        (`rial-platform-worker-staging` only today) picks that event up, fetches
        every capture associated with the verification, and runs Gemini
        multi-image cross-reasoning over the batch + narrative. The resulting
        `CaseAnalysisReport` is logged to CloudWatch and (in a follow-up slice)
        persisted onto the verification.


        **Idempotency:** atomic conditional write — narrative-not-already-set
        AND status in `{pending, partially_captured`}. Either guard failing
        returns 409 `already_finalized`. The handler does NOT compare
        narratives; a retry with the same narrative still 409s. Callers wanting
        idempotency-on-same-narrative can probe via `GET /v1/verifications/:id`
        first.


        **Publish discipline:** if the bus emit fails, the DB write still
        succeeded so the request returns 200. Failing the request would force a
        retry which would then 409 against `attribute_not_exists(narrative)` and
        leave the operator stuck. Bus failure is logged for ops visibility.


        **Production status:** worker is `CASE_ANALYSIS=stub` on prod per
        ADR-002 sibling deferral — the endpoint accepts the call and the event
        fires, but no real analysis runs until the first pay-customer onboards
        `verify.get-rial.com`.
      parameters:
        - schema:
            type: string
            pattern: ^vfy_[0-9A-HJKMNP-TV-Z]{26}$
            description: ULID with `vfy_` prefix.
            example: vfy_01HXYZABCDEFGHJKMNPQRSTVWX
          required: true
          name: id
          in: path
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FinalizeVerificationRequest'
            example:
              narrative: >-
                Front bumper damaged after low-speed collision in supermarket
                parking lot. Photos taken immediately after, then again at the
                workshop the next morning.
      responses:
        '200':
          description: >-
            Case finalized. Response is the updated verification with
            `narrative` now present.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
              example:
                id: vfy_01HXYZABCDEFGHJKMNPQRSTVWX
                status: completed
                capture_url: https://verify.get-rial.com/v/vfy_01HXYZABCDEFGHJKMNPQRSTVWX
                created_at: '2026-05-28T13:00:00.000Z'
                expires_at: '2026-05-28T14:00:00.000Z'
                captures_count: 3
                config:
                  max_captures: 3
                  steps:
                    - key: capture_1
                      type: image
                      description: ''
                      min: 3
                      max: 3
                  signals_required:
                    - screen
                    - ai
                  context:
                    kind: damage_claim
                    summary: Toyota Corolla front bumper
                  webhook_url: https://api.acmeinsurance.com/rial/webhooks
                  metadata:
                    claim_id: CLM-9912
                narrative: >-
                  Front bumper damaged after low-speed collision in supermarket
                  parking lot.
                verdict:
                  label: verified
                  score: 0.92
                  signals:
                    screen_detection:
                      screen_detected: false
                      confidence: 0.97
                    ai_detection:
                      label: likely_human
                      confidence: 0.88
        '400':
          description: >-
            Narrative missing, empty, or > 2KB. `fields` carries the Zod
            validation issues.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: invalid_request
                  message: Request body failed validation
                  fields:
                    - path: narrative
                      message: String must contain at most 2000 character(s)
        '404':
          description: Unknown id OR cross-tenant (channel-hidden).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: not_found
                  message: Resource not found
        '409':
          description: >-
            Case already finalized (narrative set) OR verification in terminal
            status (`completed`/`expired`/`failed`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: already_finalized
                  message: Verification case has already been finalized
        '410':
          description: Verification expired (`expires_at` < now).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: expired
                  message: Verification has expired
      security:
        - cookieSession: []
        - bearerApiKey: []
components:
  schemas:
    FinalizeVerificationRequest:
      type: object
      properties:
        narrative:
          type: string
          minLength: 1
          maxLength: 2000
      description: >-
        Body for `POST /v1/verifications/:id/finalize`. `narrative` is the
        claimant's written description that the case-analysis worker
        cross-references against the uploaded captures.
    Verification:
      type: object
      properties:
        id:
          type: string
          pattern: ^vfy_[0-9A-HJKMNP-TV-Z]{26}$
        status:
          type: string
          enum:
            - pending
            - partially_captured
            - completed
            - expired
            - failed
            - abandoned
          description: >-
            Lifecycle state. `pending` → first capture flips to
            `partially_captured` → analysis sets the verdict and transitions to
            `completed`. Terminal: `completed`, `expired`, `failed`. `abandoned`
            is a side-branch off `pending`/`partially_captured`, reported by the
            capture screen via `POST /v1/verifications/:token/progress` when the
            end user leaves before finishing — NOT terminal: a later capture
            resurrects the row to `partially_captured` like any other.
        capture_url:
          type: string
          format: uri
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        captures_count:
          type: integer
          minimum: 0
        config:
          type: object
          properties:
            max_captures:
              type: integer
              minimum: 1
              maximum: 20
              description: >-
                Derived total capacity (sum of steps[].max). `steps` is the
                authoritative spec.
            steps:
              type: array
              items:
                anyOf:
                  - type: object
                    properties:
                      key:
                        type: string
                        minLength: 1
                        maxLength: 64
                      type:
                        type: string
                        enum:
                          - image
                      description:
                        type: string
                        maxLength: 500
                      min:
                        type: integer
                        minimum: 0
                      max:
                        type: integer
                        minimum: 1
                    required:
                      - key
                      - type
                      - description
                      - min
                      - max
                  - type: object
                    properties:
                      key:
                        type: string
                        minLength: 1
                        maxLength: 64
                      type:
                        type: string
                        enum:
                          - text
                      description:
                        type: string
                        maxLength: 500
                      required:
                        type: boolean
                      validation:
                        type: object
                        properties:
                          min_length:
                            type: integer
                            minimum: 0
                          max_length:
                            type: integer
                            minimum: 1
                            maximum: 2000
                          format:
                            type: string
                            enum:
                              - free_text
                              - numeric_id
                              - alphanumeric_id
                        required:
                          - max_length
                          - format
                    required:
                      - key
                      - type
                      - description
                      - required
                      - validation
                  - type: object
                    properties:
                      key:
                        type: string
                        minLength: 1
                        maxLength: 64
                      type:
                        type: string
                        enum:
                          - upload
                      description:
                        type: string
                        maxLength: 500
                      min:
                        type: integer
                        minimum: 0
                      max:
                        type: integer
                        minimum: 1
                      accept:
                        type: array
                        items:
                          type: string
                          enum:
                            - image/jpeg
                            - image/png
                            - image/webp
                            - image/heic
                            - image/heif
                      max_size_bytes:
                        type: integer
                        minimum: 1
                        maximum: 26214400
                    required:
                      - key
                      - type
                      - description
                      - min
                      - max
                      - accept
                      - max_size_bytes
                description: >-
                  One slot of the capture spec — a discriminated union on
                  `type`. IMAGE slots: `key` is the stable slot id echoed back
                  as `step_key` on every capture; `description` is the
                  instruction shown to the person capturing; `min`/`max` bound
                  how many captures the slot takes (`min: 0` = optional slot).
                  TEXT slots are declared form fields answered on the capture
                  page: `required` gates completion, `validation` is declarative
                  and bounded (length bounds + an enumerated format —
                  `numeric_id` digits only, `alphanumeric_id`
                  letters/digits/hyphens; no arbitrary regex). UPLOAD slots are
                  filled with preexisting content the client uploads instead of
                  capturing live: `accept` is the allowed media types (defaults
                  to all supported image types), `max_size_bytes` bounds each
                  upload (defaults to 10 MB, hard-capped at 25 MB).
            signals_required:
              type: array
              items:
                type: string
                enum:
                  - screen
                  - ai
                  - reverse
                  - context
                  - depth
                description: >-
                  A fraud-signal the tenant requires for the verification to
                  count as complete.
            mode:
              type: string
              enum:
                - verification
                - audit
              description: >-
                Echo of the `mode` the verification was created with. `audit`
                marks a run whose evidence is preexisting content pushed via API
                rather than captured live by a device — `steps` cannot stand in
                for it, since an audited file fills an ordinary `image` slot.
                Absent when the mint did not set it.
            context:
              type: object
              properties:
                kind:
                  type: string
                  minLength: 1
                  maxLength: 64
                summary:
                  type: string
                  minLength: 1
                  maxLength: 500
                attributes:
                  type: object
                  additionalProperties:
                    anyOf:
                      - type: string
                      - type: number
                      - type: boolean
              required:
                - kind
                - summary
              description: >-
                Free-form context the tenant attaches at issue time. The
                analysis pipeline cross-references this against what is observed
                in the capture (the `context_match` signal). Shape is identical
                to the aiornot-proxy `/reports/cross-check` `context` field —
                reuse, not translation.
            webhook_url:
              type: string
              format: uri
            notify_email:
              type: string
              format: email
            metadata:
              type: object
              additionalProperties:
                type: string
            expected_location:
              type: string
              description: >-
                Address the captures are expected to happen at; activates
                presence verification (`location_match`).
            expected_object:
              type: string
              description: >-
                Free-text description of the object the photo must show;
                activates the object check (`object_match`).
            condition_aspects:
              type: array
              items:
                type: string
              description: >-
                Free-form tenant-defined aspect names the condition check scores
                — any language, any domain, no fixed vocabulary (1..6 items,
                each non-empty and ≤40 chars); activates `condition`.
          required:
            - max_captures
            - steps
            - signals_required
          description: >-
            Tenant-supplied configuration baked into the token at issue time.
            Immutable after creation — changing config means issuing a new
            verification. `steps` is the normalized capture spec: requests that
            sent `max_captures: N` read back as a single anonymous step `{ key:
            "capture_1", min: N, max: N }`.
        answers:
          type: object
          additionalProperties:
            type: object
            properties:
              value:
                type: string
                minLength: 1
                maxLength: 2000
              answered_at:
                type: string
                format: date-time
            required:
              - value
              - answered_at
            description: >-
              One answered text step. `value` is USER-DECLARED — typed by the
              person capturing, never sensor-attested like the photos. Potential
              PII: it rides the operator/developer contracts and the webhook
              only, never OG cards or third-party public surfaces.
        record_seal:
          type: object
          properties:
            algorithm:
              type: string
              enum:
                - sha256
            hash:
              type: string
              minLength: 64
              maxLength: 64
            covers:
              type: array
              items:
                type: string
                enum:
                  - record
                  - answers
            answers_provenance:
              type: string
              enum:
                - user_declared
          required:
            - algorithm
            - hash
            - covers
            - answers_provenance
          description: >-
            Integrity seal over the verification record including `answers`.
            Present only when answers exist and sealing is on (`seal !==
            false`). The hash proves the stored answers have not changed — it
            does NOT claim they are true or sensor-attested;
            `answers_provenance` carries that distinction explicitly.
        verdict:
          type: object
          properties:
            label:
              type: string
              enum:
                - verified
                - suspicious
                - failed
            score:
              type: number
              minimum: 0
              maximum: 1
            signals:
              type: object
              properties:
                screen_detection:
                  anyOf:
                    - type: object
                      properties:
                        screen_detected:
                          type: boolean
                        confidence:
                          type: number
                          minimum: 0
                          maximum: 1
                      required:
                        - screen_detected
                        - confidence
                    - type: object
                      properties:
                        status:
                          type: string
                          enum:
                            - not_applicable
                        reason:
                          type: string
                          enum:
                            - uploaded_content
                      required:
                        - status
                        - reason
                ai_detection:
                  anyOf:
                    - type: object
                      properties:
                        label:
                          type: string
                          enum:
                            - likely_human
                            - likely_ai
                            - unknown
                        confidence:
                          type: number
                          minimum: 0
                          maximum: 1
                      required:
                        - label
                        - confidence
                    - type: object
                      properties:
                        status:
                          type: string
                          enum:
                            - not_applicable
                        reason:
                          type: string
                          enum:
                            - uploaded_content
                      required:
                        - status
                        - reason
                reverse_search:
                  anyOf:
                    - type: object
                      properties:
                        found_online:
                          type: boolean
                        match_count:
                          type: integer
                          minimum: 0
                      required:
                        - found_online
                        - match_count
                    - type: object
                      properties:
                        status:
                          type: string
                          enum:
                            - not_applicable
                        reason:
                          type: string
                          enum:
                            - uploaded_content
                      required:
                        - status
                        - reason
                depth:
                  anyOf:
                    - type: object
                      properties:
                        source:
                          type: string
                        confidence:
                          type: number
                          minimum: 0
                          maximum: 1
                      required:
                        - source
                        - confidence
                    - type: object
                      properties:
                        status:
                          type: string
                          enum:
                            - not_applicable
                        reason:
                          type: string
                          enum:
                            - uploaded_content
                      required:
                        - status
                        - reason
              description: >-
                Per-signal evidence behind the verdict. Each signal is either
                its evaluated shape, or `{ status: "not_applicable", reason:
                "uploaded_content" }` when the slot was never evaluated because
                the underlying content was uploaded rather than captured live —
                never rendered as a failure.
            reason_code:
              type: string
              enum:
                - screen_detected
                - ai_generated
                - context_mismatch
                - reverse_search_match
                - depth_anomaly
              description: >-
                Which signal drove a non-`verified` label, in priority order
                (screen > AI > context > reverse search > depth) when more than
                one tripped. Absent on `verified` verdicts.
          required:
            - label
            - score
            - signals
          description: >-
            Aggregate verdict produced by the per-capture analysis pipeline.
            `label` is the human-readable bucket; `score` is the confidence in
            the verdict.
        narrative:
          type: string
          maxLength: 2000
        case_analysis:
          type: object
          properties:
            overall_risk_score:
              type: number
              minimum: 0
              maximum: 1
            confidence:
              type: number
              minimum: 0
              maximum: 1
            dimensions:
              type: object
              properties:
                narrative_image_match:
                  type: object
                  properties:
                    score:
                      type: number
                      minimum: 0
                      maximum: 1
                    evidence:
                      type: string
                      minLength: 1
                      maxLength: 1000
                    concerns:
                      type: array
                      items:
                        type: string
                        minLength: 1
                        maxLength: 300
                      maxItems: 12
                  required:
                    - score
                    - evidence
                cross_image_consistency:
                  type: object
                  properties:
                    score:
                      type: number
                      minimum: 0
                      maximum: 1
                    evidence:
                      type: string
                      minLength: 1
                      maxLength: 1000
                    concerns:
                      type: array
                      items:
                        type: string
                        minLength: 1
                        maxLength: 300
                      maxItems: 12
                  required:
                    - score
                    - evidence
                physical_plausibility:
                  type: object
                  properties:
                    score:
                      type: number
                      minimum: 0
                      maximum: 1
                    evidence:
                      type: string
                      minLength: 1
                      maxLength: 1000
                    concerns:
                      type: array
                      items:
                        type: string
                        minLength: 1
                        maxLength: 300
                      maxItems: 12
                  required:
                    - score
                    - evidence
              required:
                - cross_image_consistency
                - physical_plausibility
            review_priority:
              type: string
              enum:
                - low
                - medium
                - high
            uncertainty_areas:
              type: array
              items:
                type: string
                minLength: 1
                maxLength: 300
              maxItems: 12
            model:
              type: string
              minLength: 1
              maxLength: 200
            rubric_version:
              type: integer
              minimum: 1
              maximum: 10
            consistency_level:
              type: number
              minimum: 0
              maximum: 1
            analysis:
              type: array
              items:
                type: object
                properties:
                  capture_id:
                    type: string
                    minLength: 1
                    maxLength: 64
                  ai:
                    type: string
                    enum:
                      - low
                      - medium
                      - high
                  screen:
                    type: boolean
                required:
                  - capture_id
                  - ai
                  - screen
              maxItems: 25
            text_analysis:
              type: string
              minLength: 1
              maxLength: 2000
          required:
            - overall_risk_score
            - confidence
            - dimensions
            - review_priority
            - uncertainty_areas
            - model
          description: >-
            Structured output from the multi-image case-analysis worker (Gemini
            batch reasoning). Per-dimension scores 0..1 where 0 = nothing
            suspicious on that dimension and 1 = that dimension alone is grounds
            to reject. `overall_risk_score` aggregates with cross-dimensional
            reasoning, not a simple average.
        object_match:
          type: object
          properties:
            status:
              type: string
              enum:
                - match
                - mismatch
                - inconclusive
            expected_object:
              type: string
          required:
            - status
            - expected_object
          description: >-
            Object-check result. Present only when the verification was created
            with `expected_object`. Independent of the fraud verdict.
        condition:
          type: object
          properties:
            score:
              type: number
              minimum: 0
              maximum: 10
            label:
              type: string
              enum:
                - good
                - fair
                - poor
            aspects:
              type: array
              items:
                type: object
                properties:
                  name:
                    type: string
                  score:
                    type: number
                    minimum: 0
                    maximum: 10
                  reasoning:
                    type: string
                required:
                  - name
                  - score
                  - reasoning
                description: >-
                  One scored aspect of the condition assessment. `name` echoes
                  the free-form tenant-defined aspect from `condition_aspects`
                  verbatim — any language, any domain, no fixed vocabulary.
                  `score` is 0..10 with one decimal; `reasoning` is a
                  one-or-two-sentence visual justification.
          required:
            - score
            - label
            - aspects
          description: >-
            Condition assessment. Present only when the verification was created
            with `condition_aspects` — the free-form tenant-defined aspect names
            (any language, any domain). `score` is the one-decimal average of
            aspect scores; `label` buckets it (>=7.5 good, >=5 fair, else poor).
            Independent of the fraud verdict.
        progress:
          type: object
          properties:
            schema_version:
              type: number
              enum:
                - 1
            step:
              type: string
              enum:
                - landing
                - ready
                - capturing
                - reviewing
                - uploading
                - camera_blocked
                - success
            step_index:
              type: integer
              minimum: 0
            camera_permission:
              type: string
              enum:
                - granted
                - denied
                - unavailable
                - unknown
            elapsed_ms:
              type: integer
              minimum: 0
            updated_at:
              type: string
              format: date-time
          required:
            - schema_version
            - step
            - elapsed_ms
            - updated_at
          description: >-
            Latest funnel-progress snapshot reported via `POST
            /v1/verifications/:token/progress`. A snapshot, not an event log —
            each call overwrites the previous one. Operator-only; never present
            on the public projection.
      required:
        - id
        - status
        - capture_url
        - created_at
        - expires_at
        - captures_count
        - config
      description: >-
        Canonical wire shape for a verification. `narrative` is present only
        after `POST /v1/verifications/:id/finalize` ran. `verdict` is present
        only after analysis completed. `case_analysis` is the structured Gemini
        multi-image rubric, embedded inline by the sync finalize path when
        analysis succeeded.
    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            fields:
              type: array
              items:
                type: object
                properties:
                  path:
                    type: string
                  message:
                    type: string
                required:
                  - path
                  - message
          required:
            - code
            - message
      required:
        - error
      description: >-
        Uniform error envelope. `code` is the stable string SDKs branch on
        (`invalid_request`, `unauthorized`, `not_found`, `expired`, `step_full`,
        `unknown_step`, `steps_incomplete`, `already_finalized`,
        `too_many_requests`, `storage_unavailable`, `internal_error`). `fields`
        is only present on `invalid_request` validation failures.
  securitySchemes:
    cookieSession:
      type: apiKey
      in: cookie
      name: rial_session
      description: >-
        Dashboard / operator session cookie. Set by `/v1/auth/google/callback`
        after Google OAuth, JWT signed with the rotating
        `rial-platform/session-signing` HMAC. Read on every request the SPA
        makes with `credentials: include`. Browser-only — server-to-server
        callers use `bearerApiKey` instead.
    bearerApiKey:
      type: http
      scheme: bearer
      bearerFormat: pk_live_<ulid>
      description: >-
        Tenant-scoped API key (`pk_live_<ulid>`). Minted by a rial operator via
        the admin API; stored as SHA-256 hash, plaintext returns once at mint
        time. Use `Authorization: Bearer pk_live_<…>`. The presented key is
        hashed and looked up via the `gsi-by-key-hash` GSI on
        `rial-platform-tenants` — a hash miss returns 401 without timing
        channel.

````