openapi: '3.1.0'
info:
  title: Unforged Nib API
  version: '2026-09-27'
  summary: Signature verification. Enrol a person's reference signatures, then check documents.
  description: |
    The Unforged Nib product API, under `/nib/`. An integration uses three calls:
    `PUT /nib/subjects/{subject_reference}/references` (enrol), `POST /nib/checks` (check) and
    `GET /nib/checks/{job_id}` (read results). Everything else is administration. The
    platform routes (the caller, API keys, the audit log, billing, organisation settings and
    the operational routes) are described in `platform.yaml`.

    **Authentication.** `Authorization: Bearer <API key>` on every authenticated route. Client
    session tokens (`unf_ses_…`) are accepted only on `GET /nib/live` and on a streamed
    `POST /nib/checks` (`Accept: text/event-stream`). An API key is `unf_<env>_<opaque>`: `live`
    keys in production, then a fixed-length base62 part that carries a version, a region hint,
    the key's id and its secret. Treat the whole key as a secret. Keys are shown by their prefix
    only (`unf_live_01AbCdEf…`): the prefix is for recognising a key, never for looking one up.

    **Regions.** Your organisation's data lives in its home region, chosen when it is created
    and fixed afterwards (`GET /me` → `tenant.home_region`). Call `https://api.unforged.sh`,
    which routes each request by its key, or your region's host
    `https://<region>.api.unforged.sh` directly. A regional host answers only for the
    organisations that live there: any other caller gets 421 `wrong_region`, naming the
    `region` and its `api_host`. A key keeps working when Unforged moves your organisation
    to another region. While a move runs, every request
    for the organisation answers 503 `org_moving` with `Retry-After`.

    **Access.** Every route requires exactly one permission, named on its operation as
    `x-permission` (`none`: an unauthenticated operational route, or one that verifies its own
    requests). An API key carries an explicit set of permissions, chosen when it is created and
    never more than its creator holds; a dashboard user has the permissions of their role. A
    caller without the route's permission gets 403 `permission_denied` ("requires
    <permission>"). The catalogue, the default roles and the route table are in
    `docs/api/permissions.md` (generated; also on https://unforged.ai/security#access).

    **Dashboard users.** The Unforged dashboard calls the same routes with the signed-in
    user's WorkOS access token (`Authorization: Bearer <JWT>`, RS256, at most 8192 bytes),
    verified against WorkOS's signing keys: issuer, audience (or `client_id`), expiry. The
    token's organisation (`org_id`) must be provisioned as a tenant, else 403
    `tenant_not_provisioned`. The token's `permissions` (from the user's role) set what the
    user may do. Changes made by a user are audited as `user:<user id>`. Routes for dashboard
    users only (billing, the team, and changes to organisation settings) refuse API keys (403
    `permission_denied`, "dashboard users only"). Routes marked `x-internal` serve the web app.

    **Tenancy.** Every id is scoped to your tenant. Another tenant's resource is answered
    exactly like a missing one: 404 `not_found`, never 403.

    **Organisations.** A caller with `org:children:access` may send `Unforged-Org: <org id>` on
    any authenticated request to act in a descendant organisation (one organisation per
    request, audited there as `org.act_as`); see `platform.yaml`.

    **Errors** are RFC 9457 `application/problem+json` documents with a stable `code`
    (see the `Problem` schema). **Every response** carries `X-Request-Id`: yours, when you
    send one of 1–128 `[A-Za-z0-9._-]`, else a generated `req_<ulid>`.

    **Idempotency.** Every POST needs an `Idempotency-Key` (1–255 visible ASCII characters)
    except the ones that return a secret shown once (`/nib/sessions`, `/keys`,
    `/nib/bucket-keys`, `/nib/webhooks`). Keys are kept 24 h per tenant. The same key with the
    same body replays the original response (`Idempotent-Replayed: true`); the same key with a
    different body is 409 `idempotency_conflict`; while the first request runs, 409
    `idempotency_in_progress`. Only successful responses (and the committed outcomes of checks)
    are replayed; an error releases the key.

    **Billing.** Self-serve tenants pay $0.50 per billable check item (a decision of
    `AUTO_APPROVE`, `APPROVE`, `FLAG` or `FAILED`; `UNREADABLE`, `NO_SIGNATURE`, item errors
    and enrolment are free), metered through Stripe. Until the tenant's subscription is
    active (or while it is canceled), checks, client sessions, live capture and new API or
    bucket keys answer 402 `billing_required`; enrolment, reads and settings stay open, and
    a bucket drop gets a rejected `summary.json` with a `billing_required` error.

    **Limits.** A per-tenant token bucket (default 20 requests/s, burst 50) answers 429
    `rate_limited` with `Retry-After` (seconds). Monthly check-item quotas answer 429
    `quota_exceeded` with `Retry-After` until the next UTC month; a bucket drop over the
    quota gets a rejected `summary.json` with a `quota_exceeded` error instead (see
    `Manifest`). Bodies are at most 51 MB (a document of up to 50 MB plus
    the request's multipart framing)
    (413 `too_large`); a request must arrive within 120 s (408 `timeout`).
    **Ids.** `subject_reference`, `ref_id`, `template_id`, `job_id` and policy names match
    `[A-Za-z0-9._-]{1,128}` and are neither `.` nor `..`. A malformed id in a path names
    nothing (404); a malformed id in a request body is 400. A `ref_id`, and each segment of an
    item's file path, may not start with `.` (reserved for the service; 400).

    **Versioning.** There is no version in any URL. The API version is a date, sent as the
    request header `Unforged-Version: YYYY-MM-DD`. Your organisation is pinned to the version
    that was current when it was created; a request without the header uses that pin, and
    unauthenticated routes use the latest version. The header overrides the pin
    for one request (it never changes the pin), so you can test a newer version before you
    upgrade the pin in the dashboard (you can roll back within 72 hours). An unknown or
    malformed version, or one past its sunset, is 400 `invalid_version`. Every response carries
    `Unforged-Version` (the version used) and `Unforged-Latest-Version`.

    Additive changes never create a version: new endpoints, new optional parameters, new
    response fields and new values of documented open enums reach every version. **Clients
    must ignore unknown fields.** A breaking change (removing or renaming a field or endpoint,
    changing a field's type, meaning, default or status code, making a parameter required,
    adding a value to a closed enum, or changing a webhook payload's shape) ships only in a new
    dated version, with a changelog entry. Webhooks are rendered in your pinned version.

    A version stays supported for 24 months after a newer one replaces it. A request on a
    deprecated version (or to a deprecated endpoint) still succeeds, and its response also
    carries `Deprecation: @<unix seconds>` (RFC 9745), `Sunset: <HTTP date>` (RFC 8594) and
    `Link: <https://unforged.ai/nib/docs/changelog#<date>>; rel="deprecation",
    <https://unforged.ai/nib/docs/upgrading#<date>>; rel="sunset"`.
  contact:
    name: Unforged
    url: https://unforged.sh
servers:
  - url: https://api.unforged.sh
security:
  - apiKey: []
  - workosJwt: []
tags:
  - name: checks
    description: Check documents (`nib:checks:create`) and read results (`nib:checks:read`).
  - name: subjects
    description: Enrolled people and their reference signatures (`nib:subjects:read`, `nib:references:*`).
  - name: learning
    description: Reference learning.
  - name: sessions
    description: Short-lived client session tokens for end-user apps (`nib:checks:create`).
  - name: live
    description: Server-side live capture guidance over a WebSocket.
  - name: admin
    description: Policies, templates and bucket keys (reads `org:read`, changes `nib:settings:manage`).
  - name: webhooks
    description: Webhook endpoints and their delivery log (`nib:settings:manage`; listing `org:read`).
  - name: usage
    description: The month's usage and estimated charge (`org:read`).
paths:
  # -------------------------------------------------------------------------
  # checks
  /nib/checks:
    get:
      tags: [checks]
      operationId: listChecks
      x-permission: nib:checks:read
      summary: Search and list checks (jobs, or checked items), newest first
      description: |
        `view=jobs` (the default) lists jobs; `view=items` lists checked items with your
        references, for search: exact filters on `reference`, `process`, `subject_reference`,
        `decision` and `input_type`, and `q`, a prefix of an item's `reference`, its
        `subject_reference`, its file name or its check id (`job_id`, or `job_id/index`).
        `metadata` is never searched, and lists leave it out (read the item for it). Both views
        take `since` and `until` (on `created_at`) and page with an opaque `cursor`: pass the
        previous page's `next_cursor` (`null` on the last page). Cursors are stable: checks
        made while you page never shift a page. A filter of the other view (`status` and
        `source` are jobs-only) is 400 naming it; so is a cursor this route did not issue.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - name: view
          in: query
          description: |
            `jobs`, `items`, or `summary`: the checked items the item filters select, counted
            per decision (`ERROR`: items without one), with the processes seen in the date
            range (no `decision`, `cursor` or `limit`).
          schema: { type: string, enum: [jobs, items, summary], default: jobs }
        - $ref: '#/components/parameters/ListCursor'
        - $ref: '#/components/parameters/ListLimit'
        - name: since
          in: query
          description: Created at or after this instant (RFC 3339).
          schema: { type: string, format: date-time }
          example: '2026-09-01T00:00:00Z'
        - name: until
          in: query
          description: Created before this instant (RFC 3339).
          schema: { type: string, format: date-time }
        - name: status
          in: query
          description: '`view=jobs` only.'
          schema: { type: string, enum: [queued, running, completed, failed, rejected] }
        - name: source
          in: query
          description: '`view=jobs` only.'
          schema: { type: string, enum: [api, bucket] }
        - name: reference
          in: query
          description: '`view=items` and `view=summary`: your check reference, exactly.'
          schema: { $ref: '#/components/schemas/CheckReference' }
          example: APP-1042
        - name: process
          in: query
          description: '`view=items` and `view=summary`: the process, exactly.'
          schema: { $ref: '#/components/schemas/Process' }
        - name: subject_reference
          in: query
          description: '`view=items` and `view=summary`: the subject reference, exactly.'
          schema: { $ref: '#/components/schemas/Id' }
        - $ref: '#/components/parameters/DecisionFilter'
        - name: input_type
          in: query
          description: '`view=items` and `view=summary`: e.g. `photo`, `scan`, `form`, `signature_image`.'
          schema: { type: string, pattern: '^[a-z_]{1,32}$' }
        - name: q
          in: query
          description: |
            `view=items` and `view=summary`: a prefix of the reference, the subject reference, the file name
            or the check id (`job_id` or `job_id/index`); case-sensitive, at most 128
            characters.
          schema: { type: string, maxLength: 128 }
          example: APP-10
      responses:
        '200':
          description: |
            One page of jobs (`view=jobs`) or of checked items (`view=items`), or the counts
            (`view=summary`).
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: '#/components/schemas/JobList' }
                  - { $ref: '#/components/schemas/CheckItemList' }
                  - { $ref: '#/components/schemas/ChecksSummary' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    post:
      tags: [checks]
      operationId: createCheck
      x-permission: nib:checks:create
      summary: Check one document or a batch
      description: |
        A multipart body with one JSON part `request` (a `CheckRequest`) plus one file part per
        uploaded file, named as the items name them (`file`, `references`); or a JSON
        `CheckRequest` whose inputs are all URLs (`{"url": "https://…"}`, fetched once each from
        your allowlisted hosts and S3 buckets). All the URL fetches of one request share a 60 s
        deadline (then 504 `timeout` with `reason: fetch_deadline`); a body arriving slower
        than 64 KiB per 10 s fails with `fetch_failed`, `reason: body_stalled`.

        The job id is `request.job_id`, else the `Idempotency-Key`. Exactly one item whose
        document has at most 10 pages is checked in the request: **200** with the check result.
        Anything larger is queued: **202** with `job_id` and `status_url` (also in `Location`).
        If no capacity frees up within the budget the job continues asynchronously and
        the answer is 503 `busy` with `job_id` and `status_url`; if the item exceeds its 30 s
        budget, 504 `timeout` with the same members.

        **Streaming.** With `Accept: text/event-stream` (exactly one item) the answer is a
        Server-Sent Events stream (see `x-sse-events` on the 200 response). The stream closes
        after `decision` or `error`; a `: keep-alive` comment is sent every 10 s. A stream is
        not replayable: retrying its `Idempotency-Key` is 409 `idempotency_not_replayable`
        pointing at `GET /nib/checks/{job_id}`.

        **Session tokens** may call this route only streamed, with one item whose `subject_reference`
        is the session's subject and no `references`, URL inputs, `policy`, `target`,
        `template_id` (403) or `job_id` (400). They send no `Idempotency-Key`.
      security:
        - apiKey: []
        - sessionToken: []
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: Accept
          in: header
          required: false
          description: '`text/event-stream` streams the check (Server-Sent Events).'
          schema: { type: string }
          example: text/event-stream
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [request]
              properties:
                request:
                  $ref: '#/components/schemas/CheckRequest'
              additionalProperties:
                type: string
                contentMediaType: application/octet-stream
                description: A file part, named as an item's `file` or `references` entry.
            encoding:
              request:
                contentType: application/json
          application/json:
            schema: { $ref: '#/components/schemas/CheckRequest' }
            examples:
              byUrl:
                summary: One document by URL, against an enrolled subject
                value:
                  items:
                    - file: { url: 'https://files.example.com/app-1041.png?sig=abc' }
                      subject_reference: cust-88
                      target: { page: 1, box: applicant_signature }
                  policy: default
                  metadata: { case_ref: A-1234 }
      responses:
        '200':
          description: |
            A synchronous check: the item's check result. With `Accept: text/event-stream`, the
            event stream.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckResult' }
            text/event-stream:
              schema:
                type: string
                description: |
                  `event: <name>` / `data: <JSON>` records, in order: `received`, then (when the
                  item is compared) `quality`, `located`, `comparing`, and finally
                  `decision` or `error`.
              x-sse-events:
                received:
                  description: The job is registered.
                  type: object
                  required: [job_id]
                  properties:
                    job_id: { type: string }
                  examples:
                    - { job_id: chk_01j9x3m8v7k2q4r5s6t7u8v9w0 }
                quality:
                  description: The image quality of the first page (downscaled), with a capture cue.
                  $ref: '#/components/schemas/SseQuality'
                located:
                  description: The signature regions found.
                  $ref: '#/components/schemas/SseLocated'
                comparing:
                  description: The number of references compared against.
                  type: object
                  required: [reference_count]
                  properties:
                    reference_count: { type: integer }
                  examples:
                    - { reference_count: 3 }
                decision:
                  description: The full check result; the stream then closes.
                  $ref: '#/components/schemas/CheckResult'
                error:
                  description: |
                    A §7.5 problem document (with `job_id` and `status_url` when the job
                    continues asynchronously); the stream then closes.
                  $ref: '#/components/schemas/Problem'
        '202':
          description: Queued; poll `status_url`.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Location: { $ref: '#/components/headers/Location' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckAccepted' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '408': { $ref: '#/components/responses/RequestTimeout' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMedia' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        '504': { $ref: '#/components/responses/Timeout' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/checks/{job_id}:
    get:
      tags: [checks]
      operationId: getCheck
      x-permission: nib:checks:read
      summary: A job's status, summary and results
      description: |
        Results are paginated by `item_index`: pass `next_cursor` as `cursor`. With
        `format=ndjson` a finished job's result lines stream as `application/x-ndjson`
        (409 `conflict` while it runs). `decision` keeps only the items with those decisions
        (`ERROR`: items without one); the pagination is unchanged.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/JobId'
        - name: cursor
          in: query
          schema: { type: string }
          description: The previous page's `next_cursor` (the last `item_index` returned).
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: format
          in: query
          schema: { type: string, enum: [json, ndjson], default: json }
        - $ref: '#/components/parameters/DecisionFilter'
      responses:
        '200':
          description: The job.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Job' }
            application/x-ndjson:
              schema:
                type: string
                description: One `CheckResult` JSON document per line.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/checks/{job_id}/items/{index}/image:
    get:
      tags: [checks]
      operationId: getCheckItemImage
      x-permission: nib:checks:read
      summary: The questioned signature, cropped (PNG)
      description: |
        A grey PNG of one located signature of the item: its box with 8% padding on each side,
        at most 640 px on the long side, rendered from the item's input. The crop is kept
        beside that input (`nib/inbox/<job_id>/.crops/`) and never outlives it: it expires
        with the inbox (`inbox_ttl`, 24 h by default) and with the job's history. 404 when the
        item has no located signature, or when its input has expired (`detail`: "the input
        has expired"). Images have their own rate limit (50/s, burst 200): they never use your
        API rate.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/JobId'
        - $ref: '#/components/parameters/ItemIndex'
        - name: signature
          in: query
          description: The signature's `index` (default the best match, else the first).
          schema: { type: integer, minimum: 0 }
      responses:
        '200':
          description: The crop.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Cache-Control:
              description: '`private, no-store`'
              schema: { type: string }
            X-Content-Type-Options:
              description: '`nosniff`'
              schema: { type: string }
          content:
            image/png:
              schema: { type: string, contentMediaType: image/png }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/checks/{job_id}/items/{index}/confirm:
    post:
      tags: [checks, learning]
      operationId: confirmItem
      x-permission: nib:references:write
      summary: Confirm an item's signature as genuine
      description: |
        Records a reviewer's confirmation (the first is kept; audited as `check.confirm`) and,
        unless your tenant's learning is off, queues the item for learning. The entry bar is
        applied later (parent §8.2). No body. See `docs/api/learning.md`.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/JobId'
        - $ref: '#/components/parameters/ItemIndex'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Recorded.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Confirmed' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # subjects
  /nib/subjects:
    get:
      tags: [subjects]
      operationId: listSubjects
      x-permission: nib:subjects:read
      summary: List subjects, newest first
      description: |
        Your subjects with their reference counts (live references; `anchors` are the ones you
        enrolled, `learned` the ones Nib learned) and their last check. `q` is a prefix of the
        subject reference. Page with `cursor` (the previous page's `next_cursor`).
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/ListCursor'
        - $ref: '#/components/parameters/ListLimit'
        - name: q
          in: query
          description: A prefix of the subject reference.
          schema: { type: string, pattern: '^[A-Za-z0-9._-]{0,128}$' }
          example: cust-
      responses:
        '200':
          description: One page of subjects.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubjectList' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/subjects/{subject_reference}:
    get:
      tags: [subjects]
      operationId: getSubject
      x-permission: nib:subjects:read
      summary: A subject and its references, with provenance
      description: |
        Also the subject's checks over time: `checks.recent` holds its newest 25 checked items
        (as in `GET /nib/checks?view=items`), `checks.total` counts them all, and `checks.next`
        lists them all.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/SubjectId'
      responses:
        '200':
          description: The subject.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Subject' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    delete:
      tags: [subjects]
      operationId: deleteSubject
      x-permission: nib:references:delete
      summary: Hard-delete a subject, its references and their files
      description: Also revokes the subject's sessions. Audited.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/SubjectId'
      responses:
        '204': { $ref: '#/components/responses/NoContent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/subjects/{subject_reference}/references:
    put:
      tags: [subjects]
      operationId: putReferences
      x-permission: nib:references:write
      summary: Enrol reference signatures
      description: |
        Adds to the subject's references, creating the subject if new. Multipart: one file part
        per reference, named by its `ref_id` (PNG, JPEG, TIFF or PDF; at most 20 files and
        20 MB in total), each optionally with a text field `source.<ref_id>`. Or JSON:
        references by URL. An upload replaces that `ref_id`. Up to 3 references are enrolled in
        the request (200); more are queued (202, each `queued`). `ref_id`s starting with
        `learned-` are reserved (400). A PUT needs no `Idempotency-Key`: it is idempotent.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/SubjectId'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              additionalProperties:
                type: string
                description: |
                  A file part named `<ref_id>`, or a text field `source.<ref_id>`
                  (`signature_card`, `confirmed_past` or `unverified`, the default).
          application/json:
            schema: { $ref: '#/components/schemas/ReferencesByUrl' }
      responses:
        '200':
          description: Every reference enrolled (or rejected) in the request.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReferencesResult' }
        '202':
          description: Some references were queued for enrolment.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReferencesResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMedia' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        '504': { $ref: '#/components/responses/Timeout' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/subjects/{subject_reference}/references/{ref_id}:
    delete:
      tags: [subjects]
      operationId: deleteReference
      x-permission: nib:references:delete
      summary: Hard-delete one reference and its files
      description: A learned reference (`learned-…`) is removed with `unlearn` instead (409).
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/SubjectId'
        - $ref: '#/components/parameters/RefId'
      responses:
        '204': { $ref: '#/components/responses/NoContent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/subjects/{subject_reference}/references/{ref_id}/image:
    get:
      tags: [subjects]
      operationId: getReferenceImage
      x-permission: nib:subjects:read
      summary: A reference signature's thumbnail (PNG)
      description: |
        A grey PNG of the stored reference (its first page), at most 512 px on the long side.
        404 when the reference or its file is gone, or it was unlearned. Images have their own rate limit (50/s,
        burst 200): they never use your API rate.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/SubjectId'
        - $ref: '#/components/parameters/RefId'
      responses:
        '200':
          description: The thumbnail.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Cache-Control:
              description: '`private, no-store`'
              schema: { type: string }
            X-Content-Type-Options:
              description: '`nosniff`'
              schema: { type: string }
          content:
            image/png:
              schema: { type: string, contentMediaType: image/png }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/subjects/{subject_reference}/references/unlearn:
    post:
      tags: [subjects, learning]
      operationId: unlearnReferences
      x-permission: nib:references:delete
      summary: Remove learned references
      description: |
        Removes learned references by time (`since`, by check time) or by source check, deletes
        their files and blocks those checks from ever being learned again. `since` blocks the
        checks recorded up to this call; a job still running then (submitted inside the window)
        records its checks later and is not blocked. Audited
        (`reference.unlearn`). See `docs/api/learning.md`.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/SubjectId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UnlearnRequest' }
      responses:
        '200':
          description: The learned references removed (possibly none).
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Unlearned' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    delete:
      tags: [subjects]
      operationId: deleteReferenceNamedUnlearn
      x-permission: nib:references:delete
      summary: Hard-delete the reference whose ref_id is `unlearn`
      description: |
        The same as `DELETE /nib/subjects/{subject_reference}/references/{ref_id}` for the one
        `ref_id` that the static `unlearn` path shadows.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/SubjectId'
      responses:
        '204': { $ref: '#/components/responses/NoContent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # sessions and live capture
  /nib/usage:
    get:
      tags: [usage]
      operationId: getUsage
      x-permission: org:read
      summary: The month's usage and estimated charge
      description: |
        One UTC calendar month (`period`: `current`, the default, or `YYYY-MM`). `checks` and
        `billable_checks` count the checked items and the billable ones (a decision of
        `AUTO_APPROVE`, `APPROVE`, `FLAG` or `FAILED`); `not_charged` and `decisions` count
        the month's checked items by decision (`errors`: items without one). The estimate is
        $0.50 per billable check. For an organisation billed through its parent
        (`billing.mode: parent`) the parent is invoiced; the amount is still shown. `daily`
        lists every day of the month that has begun.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - name: period
          in: query
          schema: { type: string, pattern: '^(current|\d{4}-\d{2})$', default: current }
          example: '2026-09'
      responses:
        '200':
          description: The month's usage.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Usage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/sessions:
    post:
      tags: [sessions]
      operationId: createSession
      x-permission: nib:checks:create
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Create a client session token
      description: |
        For end-user apps, which never hold an API key. The token is limited to one subject
        and one streamed check, works only on `GET /nib/live` and streamed `POST /nib/checks`,
        and is shown once (`Cache-Control: no-store`). No `Idempotency-Key` (a stored response
        would keep the token): a retry mints another session. 404 for an unknown subject.
        While the organisation (or one above it) is suspended, its session tokens are refused
        with 401 `unauthorized`, like expired ones.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionCreate' }
      responses:
        '201':
          description: Created.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Cache-Control: { $ref: '#/components/headers/NoStore' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Session' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/sessions/{session_id}:
    delete:
      tags: [sessions]
      operationId: revokeSession
      x-permission: nib:checks:create
      summary: Revoke a session
      description: 204 also when already revoked. Open live connections of it close within 30 s.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/SessionId'
      responses:
        '204': { $ref: '#/components/responses/NoContent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/live:
    get:
      tags: [live]
      operationId: liveGuidance
      x-permission: nib:checks:create
      summary: Live capture guidance (WebSocket)
      description: |
        A WebSocket upgrade. Authenticate with an API key or dashboard token holding `nib:checks:create`, or a session token in
        `Authorization: Bearer …`; browsers, which cannot set that header, send
        `Sec-WebSocket-Protocol: unforged.session, <session token>` and the server selects
        `unforged.session`.

        Send each downscaled preview frame as one **binary** message: a PNG or JPEG of at most
        640 px on its long side and 512 KB, at most 5 per second. Each message gets exactly one
        JSON text reply, in order (see `x-websocket-messages`). Frames are analysed in memory
        and never stored.

        A connection closes after 60 s without a data message, after 10 minutes, and when its
        credential is no longer valid (close code 1008, checked every 30 s): the session expires
        or is revoked, the API key is revoked, or the organisation (or the one acted in with
        `Unforged-Org`) is suspended. At most 20 open connections per tenant
        (429 before the upgrade) and 200 per instance (503).
      security:
        - apiKey: []
        - sessionToken: []
        - sessionSubprotocol: []
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - name: Upgrade
          in: header
          required: true
          schema: { type: string, const: websocket }
        - name: Sec-WebSocket-Protocol
          in: header
          required: false
          description: '`unforged.session, <session token>` (browsers).'
          schema: { type: string }
      responses:
        '101':
          description: Switching protocols; the WebSocket is open.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Sec-WebSocket-Protocol:
              description: '`unforged.session` when the token came as a subprotocol.'
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }
      x-websocket-messages:
        client: Binary messages, one PNG or JPEG preview frame each.
        server:
          $ref: '#/components/schemas/LiveMessage'

  # -------------------------------------------------------------------------
  # admin: policies
  /nib/policies:
    get:
      tags: [admin]
      operationId: listPolicies
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: The latest version of each policy
      responses:
        '200':
          description: The policies.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [policies]
                properties:
                  policies:
                    type: array
                    items: { $ref: '#/components/schemas/Policy' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/policies/{name}:
    get:
      tags: [admin]
      operationId: getPolicy
      x-permission: org:read
      summary: A policy (the latest version, or `?version=N`)
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/PolicyName'
        - name: version
          in: query
          schema: { type: integer, minimum: 1 }
      responses:
        '200':
          description: The policy version.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Policy' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    put:
      tags: [admin]
      operationId: putPolicy
      x-permission: nib:settings:manage
      summary: Store the next version of a policy
      description: |
        201 with the new version; 200 with the latest one when the body equals it (a retried
        PUT does not mint versions). Versions are immutable; checks use the latest version of
        the policy they name and record `name@version` in `versions.policy`. Audited.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/PolicyName'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PolicySpec' }
      responses:
        '200':
          description: Unchanged; the latest version.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Policy' }
        '201':
          description: A new version.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Policy' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # admin: templates
  /nib/templates:
    get:
      tags: [admin]
      operationId: listTemplates
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Form templates
      responses:
        '200':
          description: The templates.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items: { $ref: '#/components/schemas/Template' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: createTemplate
      x-permission: nib:settings:manage
      summary: Upload a form template
      description: |
        Multipart: a `template` JSON part (`TemplateCreate`) and a `blank` file part (PNG or
        PDF, at most 20 MB). 409 when the id exists (delete it first) or you have 200
        templates. Audited.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [template, blank]
              properties:
                template: { $ref: '#/components/schemas/TemplateCreate' }
                blank:
                  type: string
                  contentMediaType: application/octet-stream
            encoding:
              template:
                contentType: application/json
      responses:
        '201':
          description: Created.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Template' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMedia' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/templates/{template_id}:
    get:
      tags: [admin]
      operationId: getTemplate
      x-permission: org:read
      summary: A form template
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/TemplateId'
      responses:
        '200':
          description: The template.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Template' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    delete:
      tags: [admin]
      operationId: deleteTemplate
      x-permission: nib:settings:manage
      summary: Delete a form template and its files
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/TemplateId'
      responses:
        '204': { $ref: '#/components/responses/NoContent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # admin: bucket keys
  /nib/bucket-keys:
    get:
      tags: [admin]
      operationId: listBucketKeys
      x-permission: nib:settings:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: The S3 access keys of your bucket's IAM user
      responses:
        '200':
          description: The keys (from IAM).
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BucketKeyList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }
    post:
      tags: [admin]
      operationId: createBucketKey
      x-permission: nib:settings:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Issue an S3 access key for your bucket
      description: |
        The secret is shown **once** (`Cache-Control: no-store`). At most 2 active keys (409).
        The body, if any, must be `{}`. No `Idempotency-Key`: a retry issues another key.
        Audited.
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: false }
      responses:
        '201':
          description: Created.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Cache-Control: { $ref: '#/components/headers/NoStore' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BucketKeyCreated' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/bucket-keys/{id}:
    delete:
      tags: [admin]
      operationId: revokeBucketKey
      x-permission: nib:settings:manage
      summary: Delete an S3 access key
      description: '`id` is a `bkt_…` id or an access key id of your IAM user. Audited.'
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/BucketKeyId'
      responses:
        '204': { $ref: '#/components/responses/NoContent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '501': { $ref: '#/components/responses/NotImplemented' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # settings
  /nib/settings/retention:
    get:
      tags: [admin]
      operationId: getRetention
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: How long the job history is kept
      description: |
        The tenant's history retention (default 90 days), the `results/` lifecycle backstop
        derived from it, and the audit log's fixed retention. See `docs/api/retention.md`.
      responses:
        '200':
          description: The retention settings.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Retention' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    put:
      tags: [admin]
      operationId: putRetention
      x-permission: org:settings:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Set how long the job history is kept
      description: |
        Finished jobs (`jobs`, their result rows, `nib/results/<job_id>/` and anything left in
        `nib/inbox/<job_id>/`) are purged once older than the retention, within about a minute.
        A job that a live learned reference came from is kept until that reference is
        unlearned or evicted. Usage records (billing) and the monthly quota count are never
        affected. A change is audited (`settings.retention.set`); setting the current value
        again is not.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [history_retention]
              properties:
                history_retention: { $ref: '#/components/schemas/HistoryRetention' }
            example: { history_retention: 30 }
      responses:
        '200':
          description: Set; the new retention settings.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Retention' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/settings/learning:
    get:
      tags: [admin]
      operationId: getLearningSettings
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Reference learning settings
      description: |
        Whether accepted signatures can become extra references of their subject, and the bar
        they must reach. The defaults (learning off, 0.99, 10) until they are set. Also
        `terms_accepted`, the tenant's most recent biometric-retention terms acceptance (`null`
        if none was ever recorded; Task 9 fix round 1). See `docs/api/learning.md`.
      responses:
        '200':
          description: The learning settings.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LearningView' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    put:
      tags: [admin]
      operationId: putLearningSettings
      x-permission: nib:settings:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Set the reference learning settings
      description: |
        Replaces the three settings (all are required). `learn_min_band_lo` is 0.5 to 1 and
        `max_learned` 0 to 50; a refusal is 400 naming the field. Turning learning on (from
        `off`) is refused with 409 `learning_terms_required` unless the tenant has accepted
        the current biometric-retention terms (`POST /nib/settings/learning/terms`); turning
        it off is always allowed. The next check uses the new settings. A change is audited
        (`settings.learning`); setting the current value again is not.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LearningSettings' }
            example: { reference_learning: confirmed_only, learn_min_band_lo: 0.99, max_learned: 10 }
      responses:
        '200':
          description: Set; the new learning settings.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LearningSettings' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/settings/learning/terms:
    post:
      tags: [admin, learning]
      operationId: postLearningTerms
      x-permission: nib:settings:manage
      x-internal: true
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/IdempotencyKey'
      summary: Accept the biometric-retention terms
      description: |
        Dashboard users only (an API key gets 403 `permission_denied` "dashboard users
        only"). Records the tenant's acceptance of `terms_version` (must be the current
        version) so `PUT /nib/settings/learning` can turn learning on; audited
        `learning.terms_accepted`. See `docs/legal/learning-terms.md`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TermsAcceptanceRequest' }
            example: { terms_version: '2026-09-27' }
      responses:
        '201':
          description: Recorded.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TermsAcceptance' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  # -------------------------------------------------------------------------
  # webhooks
  /nib/webhooks:
    get:
      tags: [webhooks]
      operationId: listWebhooks
      x-permission: org:read
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Webhook endpoints (never the secrets)
      responses:
        '200':
          description: The endpoints.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema:
                type: object
                required: [webhooks]
                properties:
                  webhooks:
                    type: array
                    items: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }
    post:
      tags: [webhooks]
      operationId: createWebhook
      x-permission: nib:settings:manage
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
      summary: Create a webhook endpoint
      description: |
        The URL must be `https` (port 443, or an explicit port ≥ 1024) and resolve to public
        addresses only. Before the endpoint is created, a `webhook.test` event signed with the
        new secret is posted to the URL: anything but a 2xx within 10 s is 400
        `webhook_test_failed` and nothing is created. The secret (`whsec_…`) is shown **once**
        (`Cache-Control: no-store`). At most 20 endpoints (409). 503 `not_ready` while the
        service has no master key. No `Idempotency-Key`. See `docs/api/webhooks.md`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookCreate' }
      responses:
        '201':
          description: Created.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Cache-Control: { $ref: '#/components/headers/NoStore' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookCreated' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '413': { $ref: '#/components/responses/TooLarge' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/webhooks/{webhook_id}:
    delete:
      tags: [webhooks]
      operationId: deleteWebhook
      x-permission: nib:settings:manage
      summary: Delete a webhook endpoint
      description: Deliveries still queued for it are dropped. Audited.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '204': { $ref: '#/components/responses/NoContent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/webhooks/{webhook_id}/secret/roll:
    post:
      tags: [webhooks]
      operationId: rollWebhookSecret
      x-permission: nib:settings:manage
      summary: Replace a webhook's signing secret
      description: |
        A new secret (`whsec_…`, shown **once**, `Cache-Control: no-store`) replaces the old one
        at once: every delivery from now on, including retries of earlier events, is signed
        with the new secret only. Update your endpoint's secret straight away. 503 `not_ready`
        while the service has no master key. No `Idempotency-Key`. Audited.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: The endpoint and its new secret.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Cache-Control: { $ref: '#/components/headers/NoStore' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookCreated' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/webhooks/{webhook_id}/deliveries:
    get:
      tags: [webhooks]
      operationId: listWebhookDeliveries
      x-permission: nib:settings:manage
      summary: The delivery log, one entry per attempt, newest first
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/WebhookId'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          schema: { type: string }
          description: The previous page's `next_cursor`.
      responses:
        '200':
          description: A page of attempts (kept 30 days).
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookDeliveryPage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        default: { $ref: '#/components/responses/Internal' }

  /nib/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver:
    post:
      tags: [webhooks]
      operationId: redeliverWebhook
      x-permission: nib:settings:manage
      summary: Send an attempt's event again
      description: |
        Queues a new delivery of the same event (same `id` and body). 503 `not_ready` while
        the service has no master key. Audited. The delivery log keeps attempts 30 days; an
        older `delivery_id` is 404.
      parameters:
        - $ref: '#/components/parameters/UnforgedVersion'
        - $ref: '#/components/parameters/UnforgedOrg'
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/DeliveryId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '202':
          description: Queued.
          headers:
            X-Request-Id: { $ref: '#/components/headers/RequestId' }
            Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
            Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Redelivery' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '421': { $ref: '#/components/responses/WrongRegion' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }
        default: { $ref: '#/components/responses/Internal' }

# ---------------------------------------------------------------------------
# outgoing webhook requests (see docs/api/webhooks.md)
webhooks:
  event:
    post:
      operationId: webhookEvent
      summary: An event delivered to your endpoint
      description: |
        Signed with `Unforged-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw
        body>" keyed with the full whsec_ secret>`. Also `Unforged-Event-Id` and
        `Unforged-Delivery-Id`. Reply 2xx within 10 s; other answers are retried with backoff
        for 24 h. Deliveries are at least once: deduplicate on `id`. When the endpoint is
        created it first receives one `webhook.test` event (`data: {webhook_id}`), signed the
        same way; it must answer 2xx or the endpoint is not created.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200':
          description: Any 2xx acknowledges the delivery.

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: unf_<env>_<opaque>
      description: A tenant API key holding the route's permission (`x-permission`). Its opaque part carries its organisation's region, so `https://api.unforged.sh` routes it without a lookup.
    sessionToken:
      type: http
      scheme: bearer
      bearerFormat: unf_ses_<token>
      description: |
        A client session token (`POST /nib/sessions`): only `GET /nib/live` and a streamed
        `POST /nib/checks`. A session token of a suspended organisation (or of one below a
        suspended organisation) is 401, like an expired or revoked one: session tokens are
        held by end users, so they never learn the organisation's state.
    workosJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A dashboard user's WorkOS access token (RS256, at most 8192 bytes). Its `org_id` must be
        a provisioned tenant; its `permissions` (from the user's `role`) set what the user may do.
    internalToken:
      type: http
      scheme: bearer
      description: The web server's service token (`NIB_INTERNAL_TOKEN`), for `/internal/*` only.
    sessionSubprotocol:
      type: apiKey
      in: header
      name: Sec-WebSocket-Protocol
      description: |
        Browsers' form of a session token on `GET /nib/live` only, when there is no
        `Authorization` header: `unforged.session, <session token>`. The server selects
        `unforged.session`.

  headers:
    RequestId:
      description: The request id (yours when valid, else `req_<ulid>`); also in problem bodies.
      schema: { type: string, maxLength: 128 }
      example: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, minimum: 0 }
      example: 1
    Location:
      description: The job's status URL.
      schema: { type: string }
      example: /nib/checks/inv-2291
    IdempotentReplayed:
      description: '`true` on a replayed response of an earlier request with the same key.'
      schema: { type: string, const: 'true' }
    NoStore:
      description: The body holds a secret shown once.
      schema: { type: string, const: no-store }
    WwwAuthenticate:
      schema: { type: string, const: Bearer }
    UnforgedVersion:
      description: |
        The API version this response was produced in: the request's `Unforged-Version` header
        when sent, else the organisation's pinned version (the latest version on
        unauthenticated routes).
      schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
      example: '2026-09-27'
    UnforgedLatestVersion:
      description: The latest API version.
      schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
      example: '2026-09-27'

  parameters:
    UnforgedVersion:
      name: Unforged-Version
      in: header
      required: false
      description: |
        The API version (`YYYY-MM-DD`) to use for this request, overriding the organisation's
        pinned version (it never changes the pin). An unknown or malformed version, or one past
        its sunset, is 400 `invalid_version`.
      schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
      example: '2026-09-27'
    UnforgedOrg:
      name: Unforged-Org
      in: header
      required: false
      description: |
        Act in a descendant organisation: its WorkOS organisation id (`^[A-Za-z0-9_]{1,64}$`,
        else 400 `invalid_request`). Needs `org:children:access`, and the organisation must be
        an active descendant of the caller's (and inside the caller's subtree when the caller's
        organisation requires SSO); otherwise 403 `permission_denied`, the same answer whether or
        not the organisation exists. The request then runs in the descendant with the caller's
        permissions minus `org:keys:manage`, `org:billing:manage` and `org:children:manage`, in
        the caller's API version. Every such request is audited in the descendant
        (`org.act_as`, with the caller's organisation as `actor_org_id`). Your own organisation's
        id is a no-op.
      schema: { type: string, pattern: '^[A-Za-z0-9_]{1,64}$' }
      example: org_01j9x3m8v7k2q4r5s6t7u8v9w0
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        1–255 visible ASCII characters, kept 24 h per tenant. On `POST /nib/checks` it is also
        the job id when the request names none.
      schema: { type: string, minLength: 1, maxLength: 255 }
      example: inv-2291
    ListCursor:
      name: cursor
      in: query
      description: The previous page's `next_cursor` (opaque).
      schema: { type: string }
    ListLimit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    DecisionFilter:
      name: decision
      in: query
      description: |
        A comma list of decisions, plus `ERROR` for items without one (an item error). On
        `GET /nib/checks`, `view=items` only.
      schema: { type: string }
      example: FLAG,FAILED,ERROR
    SubjectId:
      name: subject_reference
      in: path
      description: Your id for the person.
      required: true
      schema: { $ref: '#/components/schemas/Id' }
      example: cust-88
    RefId:
      name: ref_id
      in: path
      required: true
      schema: { $ref: '#/components/schemas/Id' }
      example: card-2019
    JobId:
      name: job_id
      in: path
      required: true
      schema: { $ref: '#/components/schemas/Id' }
      example: inv-2291
    ItemIndex:
      name: index
      in: path
      required: true
      description: The item's index in the request (decimal, no sign or leading zero).
      schema: { type: integer, minimum: 0 }
      example: 0
    SessionId:
      name: session_id
      in: path
      required: true
      schema: { type: string, pattern: '^ses_[0-9a-z]{26}$' }
      example: ses_01j9x3m8v7k2q4r5s6t7u8v9w0
    PolicyName:
      name: name
      in: path
      required: true
      schema: { $ref: '#/components/schemas/Id' }
      example: default
    TemplateId:
      name: template_id
      in: path
      required: true
      schema: { $ref: '#/components/schemas/Id' }
      example: benefits-form-v3
    BucketKeyId:
      name: id
      in: path
      required: true
      description: A `bkt_…` id, or an access key id of your IAM user.
      schema: { type: string }
      example: bkt_01j9x3m8v7k2q4r5s6t7u8v9w0
    WebhookId:
      name: webhook_id
      in: path
      required: true
      schema: { type: string, pattern: '^whk_[0-9a-z]{26}$' }
      example: whk_01j9x3m8v7k2q4r5s6t7u8v9w0
    DeliveryId:
      name: delivery_id
      in: path
      required: true
      schema: { type: string, pattern: '^whd_[0-9a-z]{26}$' }
      example: whd_01j9x3m8v7k2q4r5s6t7u8v9w0

  responses:
    NoContent:
      description: Done.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
    BadRequest:
      description: '`invalid_request`, `invalid_version`, `idempotency_key_required` or (Stripe''s webhook) `invalid_signature`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/invalid_request
            title: Invalid request
            status: 400
            detail: subject_reference must be a valid id ([A-Za-z0-9._-]{1,128})
            code: invalid_request
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    Unauthorized:
      description: '`unauthorized`: no, an invalid, a revoked or an expired credential.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
        WWW-Authenticate: { $ref: '#/components/headers/WwwAuthenticate' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/unauthorized
            title: Unauthorized
            status: 401
            detail: invalid or revoked API key
            code: unauthorized
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    Forbidden:
      description: '`permission_denied`: the caller lacks the route''s permission, an API key on a route for dashboard users only, a session token on another route, or a refused `Unforged-Org`; `org_suspended`: the caller''s organisation (or one above it) is suspended.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/permission_denied
            title: Forbidden
            status: 403
            detail: requires nib:settings:manage
            code: permission_denied
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    NotFound:
      description: '`not_found`: no such resource **for your tenant**.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/not_found
            title: Not found
            status: 404
            detail: subject not found
            code: not_found
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    RequestTimeout:
      description: '`timeout`: the request did not arrive within the request timeout (120 s).'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Conflict:
      description: |
        `conflict`, `idempotency_conflict` (the key was used with another body, or the job id
        with other inputs), `idempotency_in_progress` (the first request still runs) or
        `idempotency_not_replayable` (the key's response was a stream).
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/idempotency_conflict
            title: Idempotency key reused with a different request
            status: 409
            detail: this Idempotency-Key was used with a different request
            code: idempotency_conflict
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    TooLarge:
      description: '`too_large`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    UnsupportedMedia:
      description: '`unsupported_media`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unprocessable:
      description: '`fetch_failed` (with `host` and `reason`) or `not_allowlisted`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/fetch_failed
            title: Fetching a URL failed
            status: 422
            detail: 'fetching item 0 file failed: connect_failed'
            code: fetch_failed
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
            host: files.example.com
            reason: connect_failed
    RateLimited:
      description: '`rate_limited` (the per-tenant request rate) or `quota_exceeded` (the monthly quota).'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/rate_limited
            title: Rate limit exceeded
            status: 429
            detail: too many requests; retry after 1 s
            code: rate_limited
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    PaymentRequired:
      description: |
        `billing_required`: the tenant's billing does not allow checks (a Stripe-billed tenant
        without an active subscription). Add a payment method in the dashboard.
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/billing_required
            title: Billing required
            status: 402
            detail: 'add a payment method in the dashboard: /org/billing'
            code: billing_required
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
    NotImplemented:
      description: '`not_available_in_dev` (development deployments) or `not_implemented`.'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unavailable:
      description: |
        `busy` (no capacity; `Retry-After`; a check also carries `job_id` and `status_url` and
        continues asynchronously), `not_ready`, or `org_moving` (the organisation is being moved
        to another region; `Retry-After`).
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/busy
            title: Service busy
            status: 503
            detail: no capacity became free in time; the check was accepted as job inv-2291
            code: busy
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
            job_id: inv-2291
            status_url: /nib/checks/inv-2291
    Timeout:
      description: '`timeout`: over the time budget. A check continues asynchronously (`job_id`, `status_url`).'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    WrongRegion:
      description: |
        `wrong_region` (421 Misdirected Request): the caller's organisation lives in another
        region. `region` names it and `api_host` is its regional API host; call that host (or
        `https://api.unforged.sh`, which routes by the key).
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://unforged.sh/problems/wrong_region
            title: Wrong region
            status: 421
            detail: this organisation's data lives in uk-london; call https://uk-london.api.unforged.sh
            code: wrong_region
            request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
            region: uk-london
            api_host: https://uk-london.api.unforged.sh
    Internal:
      description: '`internal` (the cause is logged, never returned).'
      headers:
        X-Request-Id: { $ref: '#/components/headers/RequestId' }
        Unforged-Version: { $ref: '#/components/headers/UnforgedVersion' }
        Unforged-Latest-Version: { $ref: '#/components/headers/UnforgedLatestVersion' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    Id:
      type: string
      pattern: '^[A-Za-z0-9._-]{1,128}$'
      description: An id; neither `.` nor `..`.

    Problem:
      type: object
      description: An RFC 9457 problem document (parent §7.5).
      required: [type, title, status, detail, code]
      properties:
        type:
          type: string
          description: '`https://unforged.sh/problems/<code>`'
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code:
          type: string
          enum:
            - invalid_request
            - unsupported_media
            - too_large
            - fetch_failed
            - rate_limited
            - quota_exceeded
            - idempotency_conflict
            - internal
            - not_found
            - unauthorized
            - permission_denied
            - timeout
            - not_available_in_dev
            - method_not_allowed
            - conflict
            - busy
            - not_ready
            - idempotency_in_progress
            - idempotency_key_required
            - idempotency_not_replayable
            - not_implemented
            - not_allowlisted
            - tenant_not_provisioned
            - billing_required
            - invalid_signature
            - billing_already_active
            - invalid_version
            - org_suspended
            - wrong_region
            - org_moving
            - learning_terms_required
            - webhook_test_failed
          description: |
            `tenant_not_provisioned` (403): a dashboard token whose organisation has no tenant.
            `billing_required` (402): the tenant has no active billing. `invalid_signature`
            (400): a webhook whose signature does not verify. `billing_already_active` (409):
            the tenant already has a subscription; manage it in the billing portal.
            `invalid_version` (400): an `Unforged-Version` header that is malformed, unknown or
            past its sunset. `org_suspended` (403): the caller's organisation, or an
            organisation above it, is suspended (API keys and dashboard users alike).
            `wrong_region` (421): the caller's organisation lives in another region (see
            `region` and `api_host`).
            `org_moving` (503): Unforged is moving the organisation to another region;
            every request for it answers this, with `Retry-After`, until the move ends.
            `learning_terms_required` (409): `PUT /nib/settings/learning` refuses to turn
            learning on until the tenant accepts the current biometric-retention terms (see
            `terms_version`; `POST /nib/settings/learning/terms`).
            `webhook_test_failed` (400): `POST /nib/webhooks` sent the new endpoint a signed
            `webhook.test` event and got no 2xx within 10 s; nothing was created. The detail
            says why (the HTTP status, a timeout, or a failed connection).
        request_id: { type: string }
        region:
          type: string
          description: On `wrong_region`, the caller's home region.
        api_host:
          type: string
          description: On `wrong_region`, that region's API host.
        terms_version:
          type: string
          description: On `learning_terms_required`, the terms version the tenant must accept.
        job_id:
          type: string
          description: On `busy` / `timeout` of a check that continues asynchronously.
        status_url: { type: string }
        host:
          type: string
          description: On a failed URL fetch (`fetch_failed`, `not_allowlisted`, `too_large`, `timeout`).
        reason:
          type: string
          description: |
            On a failed URL fetch: a reason code such as `http_status`, `connect_failed`,
            `body_stalled` (the body arrived slower than 64 KiB per 10 s), `timeout` or
            `fetch_deadline` (a 504: all the URL fetches of one request share a 60 s deadline).
        checks:
          type: object
          description: On `/ready`'s `not_ready`.
        errors:
          type: array
          description: On a synchronous check whose job failed.
      examples:
        - type: https://unforged.sh/problems/not_found
          title: Not found
          status: 404
          detail: job not found
          code: not_found
          request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
        - type: https://unforged.sh/problems/timeout
          title: Request timed out
          status: 504
          detail: the item exceeded its 30 s budget; the check continues asynchronously
          code: timeout
          request_id: req_01j9x3m8v7k2q4r5s6t7u8v9w0
          job_id: inv-2291
          status_url: /nib/checks/inv-2291

    # --- the check result -----------------------------------------------------
    Decision:
      type: string
      enum: [AUTO_APPROVE, APPROVE, FLAG, FAILED, UNREADABLE, NO_SIGNATURE]

    Quality:
      type: object
      required: [ok, effective_dpi, sharpness, contrast, issues]
      properties:
        ok: { type: boolean }
        effective_dpi: { type: [number, 'null'] }
        sharpness: { type: number }
        contrast: { type: number }
        issues:
          type: array
          items: { type: string }

    Signature:
      type: object
      required:
        - index
        - page
        - bbox
        - located_by
        - quality
        - match_probability
        - band
        - reference_count
        - per_reference
        - flags
        - decision
        - reasons
        - best_match
      properties:
        index: { type: integer }
        page: { type: integer, minimum: 1 }
        bbox:
          type: array
          description: '[x, y, w, h] in the page''s input pixels.'
          items: { type: integer }
          minItems: 4
          maxItems: 4
        located_by:
          type: string
          enum: [template, detector, target, whole_image]
        quality: { $ref: '#/components/schemas/Quality' }
        match_probability: { type: [number, 'null'] }
        band:
          type: [array, 'null']
          items: { type: number }
          minItems: 2
          maxItems: 2
        reference_count: { type: integer }
        per_reference:
          type: array
          description: >-
            In a fixed order that never depends on enrolment order: the
            subject's anchors by `ref_id`, then the item's references by
            manifest path, then learned references by `ref_id`.
            `input_hashes.references` follows the same order.
          items:
            type: object
            required: [ref, probability]
            properties:
              ref:
                type: [string, 'null']
                description: The enrolled reference's `ref_…` id, or an item reference's path.
              probability: { type: number }
        flags:
          type: array
          description: >-
            Integrity and forensic flag codes, then soft flags. Soft flags cap
            the decision at FLAG and never fail a signature on their own:
            `different_instrument` (reason "Pen, pressure or writing speed
            differs from the references"), `unsteady_strokes` (reason "Stroke
            smoothness differs from the references (tremor or hesitation)")
            and `slow_uniform_strokes` (reason "Strokes are slower and more
            uniform than the references (possible tracing)").
          items: { type: string }
        decision: { $ref: '#/components/schemas/Decision' }
        reasons:
          type: array
          description: Plain-language reasons for the decision.
          items: { type: string }
        best_match: { type: boolean }

    Versions:
      type: object
      description: >-
        What produced a result: the Unforged Nib model version, your policy
        and the API version of the document's shape.
      required: [model, policy, api_version]
      properties:
        model:
          type: string
          description: The model version, e.g. `nib-2.2` (Unforged Nib v2.2).
        policy:
          type: [string, 'null']
          description: '`name@version`; `null` when no item reached the policy.'
        api_version:
          type: string
          pattern: '^\d{4}-\d{2}-\d{2}$'
      additionalProperties: false
      examples:
        - { model: nib-2.2, policy: default@7, api_version: '2026-09-27' }

    Metadata:
      type: object
      description: >-
        Your own key/value pairs, echoed back and never indexed: at most 20
        pairs, keys 1–40 characters, string values of at most 500 characters.
      maxProperties: 20
      additionalProperties: { type: string, maxLength: 500 }
      examples:
        - { branch: leeds }

    CheckReference:
      type: string
      description: >-
        Your own id for a check, e.g. an application number: 1–128 printable
        characters (no control characters). Not required to be unique.
      minLength: 1
      maxLength: 128
      examples: [APP-1042]

    Process:
      type: string
      description: >-
        Your business process, e.g. `loan-application`: 1–64 characters of
        `[a-z0-9._-]`. Used for filtering and reporting.
      pattern: '^[a-z0-9._-]{1,64}$'
      examples: [loan-application]

    ItemError:
      type: object
      required: [type, detail]
      properties:
        type: { type: string }
        detail: { type: string }

    CheckResult:
      type: object
      description: |
        One item's result, identical in API responses (sync, `GET`, NDJSON, SSE) and
        `nib/results/<job_id>/results.ndjson`. Every field is always present (`null` when
        absent). Your references (`subject_reference`, `reference`, `process`, `metadata`)
        are echoed from the item. `anchor_probability` is the best match against the
        subject's enrolled references alone whenever they were loaded; `null` when the
        subject has none.
      required:
        - id
        - item_index
        - file
        - subject_reference
        - reference
        - process
        - metadata
        - status
        - error
        - decision
        - input_type
        - pages
        - notes
        - signatures
        - anchor_probability
        - versions
        - input_hashes
        - timings_ms
      properties:
        id:
          type: string
          description: '`ver_<ulid>`'
        item_index: { type: integer }
        file: { type: [string, 'null'] }
        subject_reference:
          type: [string, 'null']
          description: Your id for the person whose enrolled references were compared.
        reference:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/CheckReference' }
        process:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/Process' }
        metadata:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/Metadata' }
        status: { type: string, enum: [completed, failed] }
        error:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/ItemError' }
        decision:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/Decision' }
        input_type: { type: [string, 'null'] }
        pages: { type: [integer, 'null'] }
        notes:
          type: array
          items: { type: string }
          description: |
            Document notes: stable codes, `code` or `code:detail`, pages numbered
            from 1. Among them:
            `colour_budget_exceeded:<page>`: stamp removal was skipped on that page, because
            its colour plane did not fit the document's colour budget (the page was read in
            grey, as a grey scan would be).
        signatures:
          type: array
          items: { $ref: '#/components/schemas/Signature' }
        anchor_probability: { type: [number, 'null'] }
        versions: { $ref: '#/components/schemas/Versions' }
        input_hashes:
          oneOf:
            - { type: 'null' }
            - type: object
              required: [questioned, references]
              properties:
                questioned: { type: string }
                references:
                  type: array
                  items: { type: string }
        timings_ms:
          type: object
          required: [total]
          properties:
            total: { type: integer }
      examples:
        - id: ver_01j9x3m8v7k2q4r5s6t7u8v9w0
          item_index: 0
          file: questioned
          subject_reference: cust-88
          reference: APP-1042
          process: loan-application
          metadata: { branch: leeds }
          status: completed
          error: null
          decision: APPROVE
          input_type: photo
          pages: 1
          notes: []
          signatures:
            - index: 0
              page: 1
              bbox: [412, 1880, 498, 160]
              located_by: template
              quality: { ok: true, effective_dpi: 214, sharpness: 812.5, contrast: 180, issues: [] }
              match_probability: 0.94
              band: [0.88, 0.97]
              reference_count: 2
              per_reference:
                - { ref: ref_01j9x3m8v7k2q4r5s6t7u8v9w1, probability: 0.95 }
                - { ref: ref_01j9x3m8v7k2q4r5s6t7u8v9w2, probability: 0.92 }
              flags: []
              decision: APPROVE
              reasons: ['Compared against 2 references', "Overall appearance is within this writer's usual range", "Shape alignment is within this writer's usual range", 'Likely genuine but below the auto-approve threshold']
              best_match: true
          anchor_probability: 0.94
          versions: { model: nib-2.2, policy: default@7, api_version: '2026-09-27' }
          input_hashes:
            questioned: 'sha256:8550d9b6affca27a5645d75b058479d2e081b16a135e2d4255e5e83714ebf987'
            references:
              - 'sha256:070a8e98f85afe04ba05542428664ec3684db24bc356133c11d41f9fab27f135'
              - 'sha256:1cf1da50e3f0be0b50197022be0eedf20fa688cf20ca1154b388098a9d2e29e1'
          timings_ms: { total: 1840 }
        - id: ver_01j9x3m8v7k2q4r5s6t7u8v9w3
          item_index: 1
          file: missing.png
          subject_reference: cust-88
          reference: APP-1043
          process: loan-application
          metadata: null
          status: failed
          error: { type: file_not_found, detail: missing.png is not in the job folder }
          decision: null
          input_type: null
          pages: null
          notes: []
          signatures: []
          anchor_probability: null
          versions: { model: nib-2.2, policy: default@7, api_version: '2026-09-27' }
          input_hashes: null
          timings_ms: { total: 0 }

    JobSummary:
      type: object
      description: |
        `nib/results/<job_id>/summary.json`: counts per decision and item errors. A rejected
        bucket drop has one job error (`item_index: null`) of type `manifest_changed` or
        `quota_exceeded`. A manifest whose `reference`, `process` or `metadata` breaks its
        limits fails with one error of type `invalid_request` naming the item (`item_index`,
        `null` for a top-level default) and the `field`, like the API's 400.
      required: [job_id, status, total, completed, failed, counts, errors, metadata, versions, started_at, completed_at]
      properties:
        job_id: { type: string }
        status: { type: string, enum: [completed, failed, rejected] }
        total: { type: integer }
        completed: { type: integer }
        failed: { type: integer }
        counts:
          type: object
          additionalProperties: { type: integer }
        errors:
          type: array
          items:
            type: object
            required: [item_index, file, field, type, detail]
            properties:
              item_index: { type: [integer, 'null'] }
              file: { type: [string, 'null'] }
              field:
                type: [string, 'null']
                description: The item field an `invalid_request` error is about.
              type: { type: string }
              detail: { type: string }
        metadata:
          description: The request's top-level `metadata` (`{}` when none).
        versions: { $ref: '#/components/schemas/Versions' }
        started_at: { type: [string, 'null'] }
        completed_at: { type: [string, 'null'] }
      examples:
        - job_id: inv-2291
          status: completed
          total: 2
          completed: 1
          failed: 1
          counts: { APPROVE: 1 }
          errors:
            - { item_index: 1, file: missing.png, field: null, type: file_not_found, detail: missing.png is not in the job folder }
          metadata: { batch: 2026-10 }
          versions: { model: nib-2.2, policy: default@7, api_version: '2026-09-27' }
          started_at: '2026-09-25T10:15:30.123Z'
          completed_at: '2026-09-25T10:15:32.456Z'

    # --- check requests and jobs ------------------------------------------
    ManifestItem:
      type: object
      description: Needs `subject_reference`, `references`, or both.
      required: [file]
      properties:
        file:
          type: string
          description: The questioned document, relative to `nib/inbox/<job_id>/`.
        subject_reference: { $ref: '#/components/schemas/Id' }
        references:
          type: array
          items: { type: string }
          description: Extra reference files, relative to `nib/inbox/<job_id>/`.
        template_id: { $ref: '#/components/schemas/Id' }
        target: { $ref: '#/components/schemas/Target' }
        reference: { $ref: '#/components/schemas/CheckReference' }
        process: { $ref: '#/components/schemas/Process' }
        metadata: { $ref: '#/components/schemas/Metadata' }
      additionalProperties: false

    Manifest:
      type: object
      description: |
        The bucket-drop manifest, `nib/inbox/<job_id>/manifest.json`, written **last**. At
        most 10 000 items; invalid items fail individually, except that a `reference`,
        `process` or `metadata` out of its limits rejects the whole manifest (a failed
        `summary.json` naming the item and the field). No path segment may be
        `.api-submission` (reserved). Objects outside `nib/` are not read.

        **Monthly quota.** A bucket drop counts its item count against the monthly check-item
        quota exactly like `POST /nib/checks`. A new job that would exceed it is not
        registered (no job, nothing checked or billed): `nib/results/<job_id>/summary.json`
        is written create-only with `status: rejected` and one job error of `type:
        quota_exceeded`. Writing the manifest again (a new object version) retries once the
        quota allows it; the checked job's own summary then replaces the rejection.
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/ManifestItem' }
        policy:
          type: string
          description: The tenant's `default` when absent.
        process:
          $ref: '#/components/schemas/Process'
          description: The default `process` of the items that name none.
        metadata:
          $ref: '#/components/schemas/Metadata'
          description: The default `metadata` of the items that carry none; echoed on the summary.
      additionalProperties: false
      examples:
        - items:
            - { file: app-1041.pdf, subject_reference: cust-88, template_id: benefits-form-v3, reference: APP-1041 }
            - file: app-1042.jpg
              references: [refs/extra-1042.png]
              target: { page: 1, box: applicant_signature }
              reference: APP-1042
              metadata: { branch: leeds }
          policy: default
          process: loan-application


    Target:
      type: object
      description: '`{page, box}` (a template box) or `{page, bbox}` (`[x, y, w, h]`); pages from 1.'
      required: [page]
      properties:
        page: { type: integer, minimum: 1 }
        box: { type: string }
        bbox:
          type: array
          items: { type: integer }
          minItems: 4
          maxItems: 4
      additionalProperties: false
      examples:
        - { page: 2, box: applicant_signature }
        - { page: 1, bbox: [412, 1880, 498, 160] }

    CheckInput:
      description: A multipart part name, or a URL fetched from your allowlisted hosts.
      oneOf:
        - type: string
        - type: object
          required: [url]
          properties:
            url: { type: string }
          additionalProperties: false

    CheckItem:
      type: object
      required: [file]
      properties:
        file: { $ref: '#/components/schemas/CheckInput' }
        subject_reference:
          $ref: '#/components/schemas/Id'
          description: Your id for the person; their enrolled references are compared.
        references:
          type: array
          items: { $ref: '#/components/schemas/CheckInput' }
        template_id: { $ref: '#/components/schemas/Id' }
        target: { $ref: '#/components/schemas/Target' }
        reference: { $ref: '#/components/schemas/CheckReference' }
        process:
          $ref: '#/components/schemas/Process'
          description: Overrides the request's `process`.
        metadata:
          $ref: '#/components/schemas/Metadata'
          description: Overrides the request's `metadata`.
      additionalProperties: false

    CheckRequest:
      type: object
      description: |
        The check request. A `reference`, `process` or `metadata` out of its limits is 400
        `invalid_request` naming the item (`item_index`) and the `field`.
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 10000
          items: { $ref: '#/components/schemas/CheckItem' }
        policy: { type: string }
        process:
          $ref: '#/components/schemas/Process'
          description: The default `process` of the items that name none.
        metadata:
          $ref: '#/components/schemas/Metadata'
          description: The default `metadata` of the items that carry none.
        job_id:
          $ref: '#/components/schemas/Id'
      additionalProperties: false
      examples:
        - items:
            - file: questioned
              subject_reference: cust-88
              template_id: benefits-form-v3
              target: { page: 2, box: applicant_signature }
              reference: APP-1042
              metadata: { branch: leeds }
          policy: default
          process: loan-application
        - items:
            - { file: { url: 'https://files.example.com/a.pdf' }, references: [{ url: 'https://files.example.com/ref.png' }] }
          job_id: inv-2291

    CheckAccepted:
      type: object
      required: [job_id, status_url]
      properties:
        job_id: { type: string }
        status_url: { type: string }
      examples:
        - { job_id: inv-2291, status_url: /nib/checks/inv-2291 }

    Job:
      type: object
      required: [job_id, status, source, items, created_at, started_at, completed_at, status_url, summary, results, next_cursor]
      properties:
        job_id: { type: string }
        status: { type: string, enum: [queued, running, completed, failed, rejected] }
        source: { type: string, enum: [api, bucket] }
        items: { type: integer }
        created_at: { type: string }
        started_at: { type: [string, 'null'] }
        completed_at: { type: [string, 'null'] }
        status_url: { type: string }
        summary:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/JobSummary' }
        results:
          type: array
          items: { $ref: '#/components/schemas/CheckResult' }
        next_cursor:
          type: [string, 'null']
          description: Pass as `cursor` for the next page; `null` on the last.
      examples:
        - job_id: inv-2291
          status: running
          source: api
          items: 40
          created_at: '2026-09-25T10:15:30.123Z'
          started_at: '2026-09-25T10:15:31.000Z'
          completed_at: null
          status_url: /nib/checks/inv-2291
          summary: null
          results: []
          next_cursor: null

    SseQuality:
      type: object
      required: [ok, issues, cue]
      properties:
        ok: { type: boolean }
        issues:
          type: array
          items: { type: string }
        cue:
          type: [string, 'null']
          description: A capture cue (`move_closer`, `hold_steady`, `reduce_glare`, …).
      examples:
        - { ok: false, issues: [low_contrast], cue: more_light }

    SseLocated:
      type: object
      required: [boxes]
      properties:
        boxes:
          type: array
          items:
            type: object
            required: [page, bbox, located_by]
            properties:
              page: { type: integer }
              bbox:
                type: array
                items: { type: integer }
              located_by: { type: string }
      examples:
        - boxes: [{ page: 1, bbox: [412, 1880, 498, 160], located_by: detector }]

    LiveMessage:
      description: One text reply per binary frame, in order.
      oneOf:
        - type: object
          required: [type, cue, page_found, signature_bbox]
          properties:
            type: { const: guidance }
            cue: { type: [string, 'null'] }
            page_found: { type: boolean }
            signature_bbox:
              type: [array, 'null']
              items: { type: integer }
        - type: object
          required: [type]
          properties:
            type: { enum: [rate_limited, busy] }
        - type: object
          required: [type, code, detail]
          properties:
            type: { const: error }
            code:
              enum: [frame_too_large, unsupported_frame, binary_frames_only, timeout, internal]
            detail: { type: string }
      examples:
        - { type: guidance, cue: hold_steady, page_found: true, signature_bbox: [120, 300, 210, 60] }
        - { type: rate_limited }
        - { type: error, code: frame_too_large, detail: frames are at most 640 px on their long side }

    # --- sessions ----------------------------------------------------------
    SessionCreate:
      type: object
      required: [subject_reference, scope]
      properties:
        subject_reference: { $ref: '#/components/schemas/Id' }
        scope:
          type: array
          items: { const: check }
          minItems: 1
          maxItems: 1
        ttl_seconds: { type: integer, minimum: 1, maximum: 900, default: 300 }
        max_checks: { type: integer, const: 1 }
      additionalProperties: false
      examples:
        - { subject_reference: cust-88, scope: [check], ttl_seconds: 300 }

    Session:
      type: object
      required: [id, token, expires_at, max_checks, subject_reference]
      properties:
        id: { type: string }
        token:
          type: string
          description: '`unf_ses_…`, shown once.'
        expires_at: { type: string }
        max_checks: { type: integer }
        subject_reference: { type: string }
      examples:
        - id: ses_01j9x3m8v7k2q4r5s6t7u8v9w0
          token: unf_ses_6H8mZ2kQ4rT7wY9aB1cD3eF5gJ7kL9mN0pQ2rS4tU6v
          expires_at: '2026-09-25T10:20:30Z'
          max_checks: 1
          subject_reference: cust-88

    # --- subjects and learning -------------------------------------------
    ReferencesByUrl:
      type: object
      required: [references]
      properties:
        references:
          type: array
          minItems: 1
          maxItems: 20
          items:
            type: object
            required: [ref_id, url]
            properties:
              ref_id: { $ref: '#/components/schemas/Id' }
              url: { type: string }
              source: { type: string, enum: [signature_card, confirmed_past, unverified] }
            additionalProperties: false
      additionalProperties: false
      examples:
        - references:
            - { ref_id: card-2019, url: 'https://files.example.com/card.png', source: signature_card }

    ReferencesResult:
      type: object
      required: [subject_reference, references]
      properties:
        subject_reference: { type: string }
        references:
          type: array
          items:
            type: object
            required: [ref_id, source, status]
            properties:
              ref_id: { type: string }
              source: { type: string }
              status: { type: string, enum: [enrolled, rejected, missing, queued] }
              ref:
                type: string
                description: The `ref_…` id (enrolled).
              reason: { type: string }
              detail: { type: string }
      examples:
        - subject_reference: cust-88
          references:
            - { ref_id: card-2019, source: signature_card, status: enrolled, ref: ref_01j9x3m8v7k2q4r5s6t7u8v9w1 }
            - { ref_id: blurry, source: unverified, status: rejected, reason: quality, detail: the image is too blurred }

    Reference:
      type: object
      required: [ref_id, ref, source, anchor, learned_from, confirmed_by, content_hash, quality, versions, created_at, unlearned_at]
      properties:
        ref_id: { type: string }
        ref: { type: string }
        source: { type: string, enum: [signature_card, confirmed_past, unverified, learned] }
        anchor:
          type: boolean
          description: Enrolled by you (never displaced by learned references).
        learned_from:
          type: [string, 'null']
          description: '`<job_id>/<index>` for a learned reference.'
        confirmed_by: { type: [string, 'null'] }
        content_hash: { type: [string, 'null'] }
        quality: { type: [object, 'null'] }
        versions:
          type: object
          description: The model version the reference was enrolled with.
          required: [model]
          properties:
            model: { type: string }
        created_at: { type: string }
        unlearned_at: { type: [string, 'null'] }

    JobListEntry:
      type: object
      required: [job_id, source, status, total, counts, created_at, completed_at]
      properties:
        job_id: { type: string }
        source: { type: string, enum: [api, bucket] }
        status: { type: string, enum: [queued, running, completed, failed, rejected] }
        total: { type: integer }
        counts:
          type: object
          description: Items per decision.
          additionalProperties: { type: integer }
        created_at: { type: string }
        completed_at: { type: [string, 'null'] }

    JobList:
      type: object
      required: [jobs, next_cursor]
      properties:
        jobs:
          type: array
          items: { $ref: '#/components/schemas/JobListEntry' }
        next_cursor: { type: [string, 'null'] }
      examples:
        - jobs:
            - job_id: inv-2291
              source: api
              status: completed
              total: 2
              counts: { APPROVE: 1 }
              created_at: '2026-09-27T14:22:05.120Z'
              completed_at: '2026-09-27T14:22:07.004Z'
          next_cursor: MTc1OTAwMjkyNTEyMDAwMC5pbnYtMjI5MQ

    CheckItemEntry:
      type: object
      description: A checked item in lists (no `metadata`; read the item for it).
      required: [job_id, index, reference, process, subject_reference, file, input_type, decision, match_probability, created_at]
      properties:
        job_id: { type: string }
        index: { type: integer }
        reference:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/CheckReference' }
        process:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/Process' }
        subject_reference: { type: [string, 'null'] }
        file: { type: [string, 'null'] }
        input_type: { type: [string, 'null'] }
        decision:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/Decision' }
        match_probability:
          type: [number, 'null']
          description: The best match's probability.
        created_at: { type: string }

    CheckItemList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/CheckItemEntry' }
        next_cursor: { type: [string, 'null'] }
      examples:
        - items:
            - job_id: inv-2291
              index: 0
              reference: APP-1042
              process: loan-application
              subject_reference: cust-88
              file: questioned
              input_type: photo
              decision: APPROVE
              match_probability: 0.94
              created_at: '2026-09-27T14:22:05.120Z'
            - job_id: inv-2291
              index: 1
              reference: APP-1043
              process: loan-application
              subject_reference: cust-88
              file: missing.png
              input_type: null
              decision: null
              match_probability: null
              created_at: '2026-09-27T14:22:05.120Z'
          next_cursor: null

    ChecksSummary:
      type: object
      required: [total, counts, processes]
      properties:
        total: { type: integer }
        counts:
          type: object
          description: Checked items per decision; `ERROR` counts the items without one.
          additionalProperties: { type: integer }
        processes:
          type: array
          description: The distinct processes of the checks in the date range (at most 200).
          items: { type: string }
      examples:
        - total: 1284
          counts: { AUTO_APPROVE: 512, APPROVE: 474, FLAG: 212, FAILED: 49, UNREADABLE: 21, NO_SIGNATURE: 13, ERROR: 3 }
          processes: [kyc-refresh, loan-application, mandate-change]

    SubjectList:
      type: object
      required: [subjects, next_cursor]
      properties:
        subjects:
          type: array
          items:
            type: object
            required: [subject_reference, created_at, last_checked_at, references]
            properties:
              subject_reference: { type: string }
              created_at: { type: string }
              last_checked_at: { type: [string, 'null'] }
              references:
                type: object
                required: [total, anchors, learned]
                properties:
                  total: { type: integer }
                  anchors: { type: integer }
                  learned: { type: integer }
        next_cursor: { type: [string, 'null'] }
      examples:
        - subjects:
            - subject_reference: cust-88
              created_at: '2026-09-25T10:15:30.123Z'
              last_checked_at: '2026-09-27T14:22:05.120Z'
              references: { total: 3, anchors: 2, learned: 1 }
          next_cursor: null

    Usage:
      type: object
      required: [period_start, period_end, checks, billable_checks, not_charged, decisions, unit_price_usd, estimated_charge_usd, daily, billing]
      properties:
        period_start: { type: string, format: date-time }
        period_end:
          type: string
          format: date-time
          description: The start of the next month (exclusive).
        checks: { type: integer }
        billable_checks: { type: integer }
        not_charged:
          type: object
          required: [UNREADABLE, NO_SIGNATURE, errors]
          properties:
            UNREADABLE: { type: integer }
            NO_SIGNATURE: { type: integer }
            errors: { type: integer }
        decisions:
          type: object
          description: The month's checked items per decision (every decision is present).
          additionalProperties: { type: integer }
        unit_price_usd: { type: string }
        estimated_charge_usd:
          type: string
          description: '`billable_checks` × `unit_price_usd`, two decimals.'
        daily:
          type: array
          items:
            type: object
            required: [date, checks, billable]
            properties:
              date: { type: string, format: date }
              checks: { type: integer }
              billable: { type: integer }
        billing:
          type: object
          required: [mode, status]
          properties:
            mode:
              type: string
              description: '`stripe`, `invoiced`, or `parent` (billed through the parent organisation).'
            status: { type: string }
      examples:
        - period_start: '2026-09-01T00:00:00Z'
          period_end: '2026-10-01T00:00:00Z'
          checks: 1284
          billable_checks: 1247
          not_charged: { UNREADABLE: 21, NO_SIGNATURE: 16, errors: 3 }
          decisions: { AUTO_APPROVE: 512, APPROVE: 474, FLAG: 212, FAILED: 49, UNREADABLE: 21, NO_SIGNATURE: 16 }
          unit_price_usd: '0.50'
          estimated_charge_usd: '623.50'
          daily:
            - { date: '2026-09-01', checks: 41, billable: 40 }
          billing: { mode: stripe, status: active }

    Subject:
      type: object
      required: [subject_reference, created_at, references, checks]
      properties:
        subject_reference: { type: string }
        created_at: { type: string }
        references:
          type: array
          items: { $ref: '#/components/schemas/Reference' }
        checks:
          type: object
          description: The subject's checks over time.
          required: [recent, total, next]
          properties:
            recent:
              type: array
              description: The newest 25 checked items naming the subject, newest first.
              items: { $ref: '#/components/schemas/CheckItemEntry' }
            total: { type: integer }
            next:
              type: string
              description: Every checked item of the subject (`GET /nib/checks?view=items&subject_reference=…`).
      examples:
        - subject_reference: cust-88
          created_at: '2026-09-25T10:15:30.123Z'
          checks:
            recent:
              - job_id: inv-2291
                index: 0
                reference: APP-1042
                process: loan-application
                subject_reference: cust-88
                file: questioned
                input_type: photo
                decision: APPROVE
                match_probability: 0.94
                created_at: '2026-09-27T14:22:05.120Z'
            total: 1
            next: /nib/checks?view=items&subject_reference=cust-88
          references:
            - ref_id: card-2019
              ref: ref_01j9x3m8v7k2q4r5s6t7u8v9w1
              source: signature_card
              anchor: true
              learned_from: null
              confirmed_by: null
              content_hash: 'sha256:070a8e98f85afe04ba05542428664ec3684db24bc356133c11d41f9fab27f135'
              quality: { ok: true }
              versions: { model: nib-2.2 }
              created_at: '2026-09-25T10:15:30.123Z'
              unlearned_at: null

    Confirmed:
      type: object
      required: [job_id, item_index, confirmed_by, confirmed_at, learning]
      properties:
        job_id: { type: string }
        item_index: { type: integer }
        confirmed_by: { type: string }
        confirmed_at: { type: string }
        learning: { type: string, enum: [queued, 'off', ineligible] }
      examples:
        - job_id: inv-2291
          item_index: 0
          confirmed_by: api_key:key_01j9x3m8v7k2q4r5s6t7u8v9w0
          confirmed_at: '2026-09-25T11:00:00Z'
          learning: queued

    UnlearnRequest:
      description: Exactly one of `since` or `check`.
      oneOf:
        - type: object
          required: [since]
          properties:
            since: { type: string, description: RFC 3339 }
          additionalProperties: false
        - type: object
          required: [check]
          properties:
            check: { type: string, description: '`<job_id>/<index>`' }
          additionalProperties: false
      examples:
        - { since: '2026-09-01T00:00:00Z' }
        - { check: inv-2291/0 }

    Unlearned:
      type: object
      required: [subject_reference, unlearned]
      properties:
        subject_reference: { type: string }
        unlearned:
          type: array
          items: { type: string }
      examples:
        - { subject_reference: cust-88, unlearned: [learned-01j9x3m8v7k2q4r5s6t7u8v9w0] }

    # --- admin ----------------------------------------------------------------
    QualityPolicy:
      type: object
      required: [min_effective_dpi, min_sharpness, min_contrast]
      properties:
        min_effective_dpi: { type: number }
        min_sharpness: { type: number }
        min_contrast: { type: number }

    PolicySpec:
      type: object
      required: [auto_threshold, approve_threshold, fail_threshold]
      properties:
        auto_approve_enabled: { type: boolean, default: false }
        auto_threshold: { type: number }
        approve_threshold: { type: number }
        fail_threshold: { type: number }
        quality:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/QualityPolicy' }
      examples:
        - { auto_approve_enabled: false, auto_threshold: 0.97, approve_threshold: 0.85, fail_threshold: 0.3 }

    Policy:
      type: object
      required: [name, version, id, auto_approve_enabled, auto_threshold, approve_threshold, fail_threshold, quality, created_by, created_at]
      properties:
        name: { type: string }
        version: { type: integer, minimum: 1 }
        id:
          type: string
          description: '`name@version`'
        auto_approve_enabled: { type: boolean }
        auto_threshold: { type: number }
        approve_threshold: { type: number }
        fail_threshold: { type: number }
        quality:
          oneOf:
            - { type: 'null' }
            - { $ref: '#/components/schemas/QualityPolicy' }
        created_by: { type: string }
        created_at: { type: string }
      examples:
        - name: strict
          version: 2
          id: strict@2
          auto_approve_enabled: false
          auto_threshold: 0.97
          approve_threshold: 0.85
          fail_threshold: 0.3
          quality: null
          created_by: api_key:key_01j9x3m8v7k2q4r5s6t7u8v9w0
          created_at: '2026-09-25T10:15:30.123Z'

    TemplateBox:
      type: object
      required: [name, x, y, w, h]
      properties:
        name: { $ref: '#/components/schemas/Id' }
        x: { type: integer }
        y: { type: integer }
        w: { type: integer, minimum: 1 }
        h: { type: integer, minimum: 1 }
      additionalProperties: false

    TemplateCreate:
      type: object
      required: [template_id, boxes]
      properties:
        template_id: { $ref: '#/components/schemas/Id' }
        name: { type: string }
        boxes:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/TemplateBox' }
      additionalProperties: false
      examples:
        - template_id: benefits-form-v3
          name: Benefits form
          boxes: [{ name: applicant_signature, x: 400, y: 1860, w: 520, h: 200 }]

    Template:
      type: object
      required: [template_id, name, boxes, blank_type, feature_hash, created_at]
      properties:
        template_id: { type: string }
        name: { type: string }
        boxes:
          type: array
          items: { $ref: '#/components/schemas/TemplateBox' }
        blank_type: { type: string, enum: [png, pdf] }
        feature_hash: { type: [string, 'null'] }
        created_at: { type: string }
      examples:
        - template_id: benefits-form-v3
          name: Benefits form
          boxes: [{ name: applicant_signature, x: 400, y: 1860, w: 520, h: 200 }]
          blank_type: png
          feature_hash: 'sha256:1cf1da50e3f0be0b50197022be0eedf20fa688cf20ca1154b388098a9d2e29e1'
          created_at: '2026-09-25T10:15:30.123Z'

    BucketKey:
      type: object
      required: [id, access_key_id, status, created_at]
      properties:
        id:
          type: [string, 'null']
          description: '`bkt_…`; `null` for keys created outside the API.'
        access_key_id: { type: string }
        status: { type: string, enum: [active, inactive] }
        created_at: { type: [string, 'null'] }
        reissue_required:
          type: boolean
          description: |
            Suspended when the organisation moved to another region: the key no longer works
            and must be replaced with a new one.

    BucketKeyList:
      type: object
      required: [iam_user, bucket_keys]
      properties:
        iam_user: { type: string }
        bucket_keys:
          type: array
          items: { $ref: '#/components/schemas/BucketKey' }
      examples:
        - iam_user: unf-prod-acme
          bucket_keys:
            - { id: bkt_01j9x3m8v7k2q4r5s6t7u8v9w0, access_key_id: AKIAIOSFODNN7EXAMPLE, status: active, created_at: '2026-09-25T10:15:30Z', reissue_required: false }

    BucketKeyCreated:
      type: object
      required: [id, access_key_id, secret_access_key, iam_user, created_at]
      properties:
        id: { type: string }
        access_key_id: { type: string }
        secret_access_key:
          type: string
          description: Shown once; never stored.
        iam_user: { type: string }
        created_at: { type: string }
      examples:
        - id: bkt_01j9x3m8v7k2q4r5s6t7u8v9w0
          access_key_id: AKIAIOSFODNN7EXAMPLE
          secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
          iam_user: unf-prod-acme
          created_at: '2026-09-25T10:15:30Z'

    HistoryRetention:
      description: |
        `"30m"` (half an hour), an integer number of days from 1 to 3650, or `"indefinite"`
        (never purged). No other values.
      oneOf:
        - type: string
          enum: ['30m', indefinite]
        - type: integer
          minimum: 1
          maximum: 3650
    Retention:
      type: object
      required: [history_retention, default, results_lifecycle_days, audit_log_retention_days]
      properties:
        history_retention: { $ref: '#/components/schemas/HistoryRetention' }
        default:
          type: boolean
          description: True while the retention was never set (90 days).
        results_lifecycle_days:
          type: [integer, 'null']
          description: |
            The bucket's `results/` lifecycle backstop: max(retention, 1 day) in whole days,
            `null` (no expiry) for `indefinite`. The purge itself is authoritative.
        audit_log_retention_days:
          type: integer
          description: The audit log's fixed retention (400 days; not selectable).
      example:
        history_retention: 90
        default: true
        results_lifecycle_days: 90
        audit_log_retention_days: 400

    LearningSettings:
      type: object
      additionalProperties: false
      required: [reference_learning, learn_min_band_lo, max_learned]
      properties:
        reference_learning:
          type: string
          enum: ['off', confirmed_only, confirmed_and_auto]
          description: |
            `off` (the default); `confirmed_only`: signatures a reviewer confirmed; and
            `confirmed_and_auto`: also AUTO_APPROVE items.
        learn_min_band_lo:
          type: number
          minimum: 0.5
          maximum: 1
          description: The lower band bound a candidate must reach against the enrolled references alone (default 0.99).
        max_learned:
          type: integer
          minimum: 0
          maximum: 50
          description: The most learned references kept per subject (default 10).
      example:
        reference_learning: confirmed_only
        learn_min_band_lo: 0.99
        max_learned: 10

    LearningView:
      type: object
      additionalProperties: false
      required: [reference_learning, learn_min_band_lo, max_learned, terms_accepted]
      properties:
        reference_learning: { $ref: '#/components/schemas/LearningSettings/properties/reference_learning' }
        learn_min_band_lo: { $ref: '#/components/schemas/LearningSettings/properties/learn_min_band_lo' }
        max_learned: { $ref: '#/components/schemas/LearningSettings/properties/max_learned' }
        terms_accepted:
          oneOf:
            - { $ref: '#/components/schemas/TermsAcceptance' }
            - type: 'null'
          description: The tenant's most recent biometric-retention terms acceptance, or `null`.
      example:
        reference_learning: confirmed_only
        learn_min_band_lo: 0.99
        max_learned: 10
        terms_accepted:
          terms_version: '2026-09-27'
          accepted_by: 'user:user_01ABCDEF'
          accepted_at: '2026-09-27T12:00:00.000Z'

    TermsAcceptanceRequest:
      type: object
      additionalProperties: false
      required: [terms_version]
      properties:
        terms_version:
          type: string
          description: Must be the current version (`nib_service::learning::LEARNING_TERMS_VERSION`).
      example: { terms_version: '2026-09-27' }

    TermsAcceptance:
      type: object
      additionalProperties: false
      required: [terms_version, accepted_by, accepted_at]
      properties:
        terms_version: { type: string }
        accepted_by:
          type: string
          description: The audit actor (`user:<sub>`; a dashboard user only).
        accepted_at: { type: string, format: date-time }
      example:
        terms_version: '2026-09-27'
        accepted_by: 'user:user_01ABCDEF'
        accepted_at: '2026-09-27T12:00:00.000Z'

    EventType:
      type: string
      enum: [job.completed, job.failed, reference.enrolled, reference.rejected]

    WebhookCreate:
      type: object
      required: [url, events]
      properties:
        url: { type: string }
        events:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/EventType' }
      additionalProperties: false
      examples:
        - { url: 'https://hooks.example.com/unforged', events: [job.completed, reference.rejected] }

    Webhook:
      type: object
      required: [id, url, events, created_at]
      properties:
        id: { type: string }
        url: { type: string }
        events:
          type: array
          items: { $ref: '#/components/schemas/EventType' }
        created_at: { type: string }

    WebhookCreated:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: '`whsec_…`, shown once. The HMAC key is this full string.'
      examples:
        - id: whk_01j9x3m8v7k2q4r5s6t7u8v9w0
          url: 'https://hooks.example.com/unforged'
          events: [job.completed, reference.rejected]
          created_at: '2026-09-25T10:15:30.123Z'
          secret: whsec_6H8mZ2kQ4rT7wY9aB1cD3eF5gJ7kL9mN0pQ2rS4tU6v

    WebhookDelivery:
      type: object
      required: [id, event_id, event_type, attempt, status, response_status, latency_ms, error, created_at, delivered_at]
      properties:
        id: { type: string }
        event_id: { type: string }
        event_type: { $ref: '#/components/schemas/EventType' }
        attempt: { type: integer, minimum: 1 }
        status: { type: string, enum: [succeeded, failed] }
        response_status: { type: [integer, 'null'] }
        latency_ms: { type: [integer, 'null'] }
        error:
          type: [string, 'null']
          description: '`http_status`, `timeout`, `connect_failed`, `request_failed`, `blocked_address`, `invalid_url`, …'
        created_at: { type: string }
        delivered_at: { type: [string, 'null'] }
        api_version:
          type: [string, 'null']
          description: The API version the payload was rendered in (your pin when it was sent).
      examples:
        - id: whd_01j9x3m8v7k2q4r5s6t7u8v9w0
          event_id: evt_01j9x3m8v7k2q4r5s6t7u8v9w1
          event_type: job.completed
          attempt: 1
          status: succeeded
          response_status: 200
          latency_ms: 84
          error: null
          created_at: '2026-09-25T10:15:31.000Z'
          delivered_at: '2026-09-25T10:15:31.084Z'

    WebhookDeliveryPage:
      type: object
      required: [deliveries, next_cursor]
      properties:
        deliveries:
          type: array
          items: { $ref: '#/components/schemas/WebhookDelivery' }
        next_cursor: { type: [string, 'null'] }

    Redelivery:
      type: object
      required: [status, redelivery_of]
      properties:
        status: { type: string, const: queued }
        redelivery_of: { type: string }
      examples:
        - { status: queued, redelivery_of: whd_01j9x3m8v7k2q4r5s6t7u8v9w0 }

    WebhookEvent:
      type: object
      required: [id, type, created_at, data]
      properties:
        id:
          type: string
          description: '`evt_…`; the same on every retry and redelivery.'
        type:
          description: An event you subscribed to, or `webhook.test` (the connection test sent once when the endpoint is created).
          anyOf:
            - $ref: '#/components/schemas/EventType'
            - { type: string, const: webhook.test }
        created_at: { type: string }
        data:
          type: object
          description: |
            The event's data (see `docs/api/webhooks.md`). `job.completed` carries `job_id`,
            `status`, `total`, `counts`, `failed`, `versions`, `status_url`, `items` (the first
            100 results, each exactly as `CheckResult`) and `items_truncated`; a job of more
            than 100 items also has `items_url`, the paged `GET` of the rest. The body is
            rendered in your pinned API version, sent as `Unforged-Version`.
      examples:
        - id: evt_01j9x3m8v7k2q4r5s6t7u8v9w0
          type: job.completed
          created_at: '2026-09-25T10:15:30.123Z'
          data:
            job_id: inv-2291
            status: completed
            total: 1
            counts: { APPROVE: 1 }
            failed: 0
            items:
              - { id: ver_01j9x3m8v7k2q4r5s6t7u8v9w0, item_index: 0, file: questioned, subject_reference: cust-88, reference: APP-1042, process: loan-application, metadata: { branch: leeds }, status: completed, error: null, decision: APPROVE, input_type: photo, pages: 1, notes: [], signatures: [], anchor_probability: 0.94, versions: { model: nib-2.2, policy: default@7, api_version: '2026-09-27' }, input_hashes: null, timings_ms: { total: 1840 } }
            items_truncated: false
            versions: { model: nib-2.2, policy: default@7, api_version: '2026-09-27' }
            status_url: /nib/checks/inv-2291

    # --- operations -------------------------------------------------------
