openapi: 3.1.0
info:
  title: Underwriting Guru API
  version: 1.1.0
  description: AI-driven underwriting engine. All endpoints are versioned under /v1. Errors are RFC 9457 application/problem+json.
servers:
  - url: https://api.underwriting.guru/v1
    description: Production
  - url: /v1
    description: Same origin as this page's host
security:
  - bearerAuth: []
paths:
  /tenants/me:
    get:
      operationId: getTenantMe
      summary: Return the authenticated caller's tenant and identity context.
      description: Resolves the bearer credential (a user JWT from the sign-in service this deployment is configured against, or an API key) to a principal and returns that principal's own tenant, read through the tenant-scoped store path. Any authenticated member of the tenant may call it.
      tags:
        - tenants
      responses:
        "200":
          description: The caller's tenant and identity context.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantMe"
              examples:
                local_user:
                  summary: A member whose credentials we hold
                  description: "`credential_management: hosted` means the sign-in service this deployment operates owns this caller's password and second factors, so a UI offers them the credential hand-off. `connection_name` is absent because there is no identity provider in the picture."
                  value:
                    tenant:
                      id: 6f9619ff-8b86-d011-b42d-00c04fc964ff
                      slug: northwind
                      name: Northwind Group
                      status: active
                      time_zone: America/Chicago
                      created_at: 2026-03-11T14:02:57Z
                    actor_type: user
                    role: reviewer
                    auth:
                      identity_source: local
                      credential_management: hosted
                federated_user:
                  summary: A member asserted by their own identity provider
                  description: "`credential_management: external` means this caller's organization owns their credentials upstream — offer no password hand-off, and name who manages it instead. `connection_name` is display text chosen by their administrator; it may be absent even here, so never require it. Note that this caller and the local one can belong to the SAME tenant: federation is never exclusive."
                  value:
                    tenant:
                      id: 6f9619ff-8b86-d011-b42d-00c04fc964ff
                      slug: northwind
                      name: Northwind Group
                      status: active
                      time_zone: America/Chicago
                      created_at: 2026-03-11T14:02:57Z
                    actor_type: user
                    role: admin
                    auth:
                      identity_source: federated
                      credential_management: external
                      connection_name: Northwind Entra ID
                api_key:
                  summary: An API-key caller
                  description: Both `role` and `auth` are ABSENT. An API key is governed by scopes rather than a role, and it is not a human identity, so it has no credentials to describe. Do not treat the missing `auth` as "local".
                  value:
                    tenant:
                      id: 6f9619ff-8b86-d011-b42d-00c04fc964ff
                      slug: northwind
                      name: Northwind Group
                      status: active
                      created_at: 2026-03-11T14:02:57Z
                    actor_type: api_key
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /tenant-settings:
    get:
      operationId: getTenantSettings
      summary: The tenant's general configuration document.
      description: "The CUSTOMER-facing tenant configuration surface — the tenant's business clock (`time_zone`, the tenant clock), its notification-retention window, its embed allowlist, and its program selection rules (`routing`); future general settings slot in as sibling properties of the same document rather than as new endpoints. Admin-role only (user principals holding `admin` or `platform`; API keys are denied regardless of scope — configuring the tenant is a human administrative act, the same posture as /api-keys). Distinct from the operator-managed policy surface: this document is the tenant's own to change."
      tags:
        - tenants
      responses:
        "200":
          description: The stored settings document. An absent `time_zone` means the tenant renders instants in UTC (the pre- default).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantSettings"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    put:
      operationId: setTenantSettings
      summary: Replace the tenant's general configuration document.
      description: |-
        Full-replace of the general settings document, mirroring the operator-managed policy surface's semantics: the request body becomes the ENTIRE stored document for the keys this surface owns — an absent `time_zone` clears the binding (back to the documented UTC default), it does not merge with what was stored. Keys owned by OTHER surfaces (the vertical binding, `evaluation_policy`) are never touched by this endpoint. `time_zone` is validated against the IANA database (Go `time.LoadLocation`); an unknown name is a 422, never stored — a stored-but-unloadable zone would silently degrade every rendered stamp to UTC (app.LocationOrUTC's documented fallback). `routing` is validated whole (known operators, per-operator value shapes, canonical-key-shaped facts, a named program per rule, and the documented bounds); every problem is reported at its own JSON pointer in one 422 rather than one mistake at a time, and nothing is stored until the whole document is storable. THOSE POINTERS ARE ROOTED AT THE REQUEST DOCUMENT you sent, not at the stored one and not at the `routing` property alone — `/routing/rules/2/when/0/op` is `op` of the first condition of the THIRD rule of this body's `routing` — so a form can map `errors[]` straight onto the field the person typed in. The 422 response below shows a worked body. An omitted `routing` CLEARS the tenant's selection rules — the proposal read then answers `matched: false` and choosing a program is manual again, which is a supported state and not a degraded one.

        Every save audits `tenant.settings.updated`, whose before/after carry every property this surface owns — so a change to the selection rules lands in the tenant's own append-only hash chain, and an auditor can tie a routing proposal a person accepted to the exact rule set in force when they accepted it (the `rules_digest` a proposal reports is the digest of this document's `routing`). Admin-role only, same gate as GET.

        RESEND EVERY KEY YOU WANT TO KEEP. This is the trap the word "full-replace" above understates, and it has a second way to bite: this is also the contract that turns embedding on (`embed_allowed_origins`). An admin who sends only `{"embed_allowed_origins": [...]}` silently clears `time_zone` — every rendered timestamp in the workspace falls back to UTC — `notification_delivery_retention_days`, the `routing` selection rules, and `break_glass_require_tenant_approval`, WHICH IS A SECURITY CONTROL: omitting that one key turns off this workspace's requirement that its own administrator approve a LendArch operator's read of its content, and reopens the operator-side approval door. GET this document first, change the one field, and PUT the whole thing back.

        A HUMAN SHOULD NOT NEED THIS ENDPOINT for the embed allowlist. Two surfaces already do the GET-then-PUT correctly: the tenant's own admin screen, Admin -> Workspace settings -> Embedding, and `ug-admin embed allow-origin list|add|remove --tenant <slug|id> --origin <https://host>` for the white-glove path. Both run the same validation this endpoint does. Reach for the API directly only when writing your own tooling — and then resend every key.
      tags:
        - tenants
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TenantSettings"
      responses:
        "200":
          description: The updated stored settings document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TenantSettings"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "422":
          description: The document was understood and refused, and NOTHING was stored. Every problem is reported at once, each at its own JSON pointer, and the pointers are rooted at the REQUEST document — so an editor can highlight every bad field from one response. The example below is one body with two problems in the same rule.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              example:
                type: urn:ug:error:validation
                title: Request validation failed
                status: 422
                detail: the program selection rules are not valid (2 problem(s))
                instance: /v1/tenant-settings
                request_id: 4d1f0c2e-6a3b-4c9d-8e7f-2b5a9c0d1e34
                errors:
                  - pointer: /routing/rules/2/when/0/op
                    message: "must be one of: eq, in, exists, gte, lte"
                  - pointer: /routing/rules/2/program
                    message: name the program this rule proposes; a rule that proposes nothing cannot be acted on
        "500":
          $ref: "#/components/responses/Problem"
  /break-glass/requests:
    get:
      operationId: listBreakGlassRequests
      summary: Operator requests to read this workspace's content.
      description: |-
        THE CUSTOMER'S SIDE OF BREAK-GLASS (threat-model B8/I). A LendArch operator may not read this workspace's content — documents, blocks, extracted facts, answer evidence, guideline text — without a live, time-boxed, approved grant. This endpoint is where the workspace's own administrators see those requests: who asked, the reason they typed, how long a window they want, whether it was approved or denied, by whom, and when it shuts.

        NAMING THE HUMAN IS THE POINT. `operator.display_name` and `operator.email` are a real internal staff identity, not a pseudonymous handle — an accountability record that cannot be checked is not an accountability record. The same addresses appear on the `breakglass.requested` / `.approved` / `.denied` / `.revoked` / `.used` events in this tenant's own audit export.

        NOBODY IS NOTIFIED WHEN A REQUEST ARRIVES. There is no webhook event type, no email and no in-app inbox for `breakglass.requested`: POLLING THIS ENDPOINT (`?status=requested`) IS THE ONLY DISCOVERY PATH, so an integrator who wants to be told has to ask. A workspace that has set `break_glass_require_tenant_approval` and then does not look here has blocked its own support.

        Newest first, cursor-paginated. Admin-role only (user principals holding `admin` or `platform`; API keys are denied regardless of scope — deciding who may read the workspace is a human administrative act, the /api-keys posture). Scoped to the caller's OWN workspace: there is no path or query parameter that names another tenant, and a grant targeting one is not listed.
      tags:
        - tenants
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/BreakGlassStatusFilter"
      responses:
        "200":
          description: A page of operator access requests on this workspace, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BreakGlassRequestList"
              examples:
                pending:
                  summary: One request awaiting this workspace's decision, and one window that was used and has since lapsed
                  value:
                    items:
                      - id: 019243b7-8f10-7a22-9c41-2b3c4d5e6f70
                        status: requested
                        reason: Investigating ticket 4821 — the parse of the 2026-08 rate sheet is returning empty blocks
                        requested_duration_seconds: 3600
                        requested_at: 2026-09-02T14:02:11Z
                        operator:
                          display_name: Sam Okonkwo
                          email: sam@lendarch.example
                        live: false
                      - id: 019243a1-2c04-7b18-8d55-6e7f8a9b0c1d
                        status: approved
                        reason: Customer asked us to confirm why the income criteria evaluated as insufficient_evidence
                        requested_duration_seconds: 7200
                        requested_at: 2026-08-28T09:14:00Z
                        operator:
                          display_name: Sam Okonkwo
                          email: sam@lendarch.example
                        approver_kind: tenant_admin
                        approved_by:
                          display_name: Dana Okafor
                          email: dana@northwind.example
                        approved_at: 2026-08-28T09:31:44Z
                        expires_at: 2026-08-28T11:31:44Z
                        live: false
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /break-glass/requests/{requestId}:
    get:
      operationId: getBreakGlassRequest
      summary: One operator access request on this workspace.
      description: "The single-request read behind the list, for the decision screen. Admin-role only, same gate and same own-workspace scoping as the list: a request targeting ANOTHER workspace is a `404`, identical to an id that does not exist, so this endpoint is not an oracle for whether somebody else is under support investigation."
      tags:
        - tenants
      parameters:
        - $ref: "#/components/parameters/BreakGlassRequestId"
      responses:
        "200":
          description: The request as this workspace sees it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BreakGlassRequest"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /break-glass/requests/{requestId}/approve:
    post:
      operationId: approveBreakGlassRequest
      summary: Approve an operator's request to read this workspace.
      description: |-
        THE CUSTOMER APPROVES.

        The approval is not reversible from here and it is not extendable: a lapsed window cannot be renewed, the operator has to ask again, and every read taken under the window writes a `breakglass.used` event on this workspace's own audit chain. The only way to shut a live window early today is to ask us to revoke it (`ug-admin break-glass revoke`); that revocation, too, lands on this workspace's chain.

        Only a `requested` grant can be approved — a second approval, or an approval of something already denied or revoked, is a `409` with `reason: not_pending`. The optional `note` is recorded verbatim in the `breakglass.approved` audit event; it is NOT a column on the grant and does not come back on the response.

        THERE IS NO SCREEN FOR THIS YET. The app's decision surface arrives in a follow-up chunk; until it does, this route and the list above ARE the surface, which is why every refusal and every operator tool points at `POST /v1/break-glass/requests/{requestId}/approve` by name rather than at a menu path.

        Whether the operator ALSO needs a second LendArch operator's approval is this workspace's own switch: `break_glass_require_tenant_approval` in `PUT /v1/tenant-settings`. With it ON, a platform-side approval is refused outright (`403`, `reason: tenant_approval_required`) and this endpoint is the only door. With it OFF — the shipped default — either door works, and a platform-side approval still needs two different operators.
      tags:
        - tenants
      parameters:
        - $ref: "#/components/parameters/BreakGlassRequestId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BreakGlassDecision"
      responses:
        "200":
          description: The grant as it now stands — `approved`, with its `expires_at` and `live`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BreakGlassRequest"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          description: "The grant is not awaiting a decision (`reason: not_pending`) — somebody already approved, denied or revoked it. Re-read it with the GET above; the state you are looking at is stale, and nothing was changed."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              example:
                type: urn:ug:error:conflict
                title: Conflict
                status: 409
                detail: "break-glass: request 019243b7-8f10-7a22-9c41-2b3c4d5e6f70 is approved, not awaiting a decision"
                reason: not_pending
                instance: /v1/break-glass/requests/019243b7-8f10-7a22-9c41-2b3c4d5e6f70/approve
                request_id: 4d1f0c2e-6a3b-4c9d-8e7f-2b5a9c0d1e34
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /break-glass/requests/{requestId}/deny:
    post:
      operationId: denyBreakGlassRequest
      summary: Refuse an operator's request to read this workspace.
      description: |-
        Says no. The grant moves to `denied` and can never be approved afterwards — the state machine is a database trigger, so a denial is final and the operator must file a new request. The optional `note` is recorded verbatim in the `breakglass.denied` audit event.

        A denial is worth recording for the same reason an approval is: "we were asked and we said no" is part of the record of how this capability is governed, and it is in this workspace's own audit export. Same gate, same own-workspace scoping and same `409 not_pending` rule as the approval.
      tags:
        - tenants
      parameters:
        - $ref: "#/components/parameters/BreakGlassRequestId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BreakGlassDecision"
      responses:
        "200":
          description: "The grant as it now stands — `denied`, with no window. THIS is the one response that carries `denied_by`: the grant has no denier column, so later reads of the same request cannot name the human and the `breakglass.denied` audit event is the permanent record."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BreakGlassRequest"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          description: "The grant is not awaiting a decision (`reason: not_pending`) — somebody already approved, denied or revoked it. Re-read it with the single GET; the state you are looking at is stale, and nothing was changed. The same rule and the same `reason` as the approval."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
              example:
                type: urn:ug:error:conflict
                title: Conflict
                status: 409
                detail: "break-glass: request 019243b7-8f10-7a22-9c41-2b3c4d5e6f70 is denied, not awaiting a decision"
                reason: not_pending
                instance: /v1/break-glass/requests/019243b7-8f10-7a22-9c41-2b3c4d5e6f70/deny
                request_id: 4d1f0c2e-6a3b-4c9d-8e7f-2b5a9c0d1e34
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /embed/handoffs:
    post:
      operationId: mintEmbedHandoff
      summary: Mint a single-use embed handoff for a named subject + case.
      description: |-
        SERVER-TO-SERVER only. The host's backend — which has already authenticated its own user through the tenant's SSO — calls this with its tenant API KEY (the `embed:mint` scope), naming the subject (the UG user's email) and the case to open (`scenario_id`) — e.g. a host platform's button that opens one case. The response is an opaque, single-use handoff token with a ≈60-second TTL, stored hashed at rest; it is the coupon the host page posts into the embed frame, which exchanges it at POST /embed/sessions for a READ-ONLY session confined to that one case.

        This creates NOTHING user-facing and does NOT verify the subject or scenario exists — an email with no active user, or a scenario that is not the tenant's, is refused at the EXCHANGE, never here, and embed is not an admission door. The token must NEVER reach a browser: it is minted backend-to-backend and carries no authority of its own. API-key principals with `embed:mint` only; user principals and keys without the scope are refused, and there is deliberately no user-role path (a human does not mint machine coupons).

        WHAT THE HOST PAGE DOES WITH THE COUPON — the three facts an integrator cannot get anywhere else in this contract, because the embed DOCUMENT is not a `/v1` path and so cannot be a path item here (`servers.url` is `/v1`):
        1. FRAME `https://<app-host>/embed/{tenant-slug}` — your workspace's slug, on the SAME host that serves this `/v1` API. Same-host is not a style preference: the frame's API calls are same-origin by construction and there is no CORS anywhere in this system, so a frame on any other host cannot talk to us at all. The handoff is deliberately NOT a URL parameter — putting it there would leak it into browser history, access logs and the next request's `Referer`.
        2. POST THE COUPON INTO THE FRAME once it has loaded: `frame.contentWindow.postMessage({ug: 1, type: "ug:init", handoff: "<the token from this response>"}, "https://<app-host>")`. The second argument is `targetOrigin` and must be the app origin, never `"*"`.
        3. POST IT ON THE IFRAME'S `load` EVENT — that is early enough, and it is a guarantee. The frame installs its `message` listener in the same task as its entry script and BUFFERS a `ug:init` that arrives before its own dispatcher is armed, replaying the real `MessageEvent` so the origin pin still comes from the browser's unforgeable value. Do not delay, retry or poll: `ug:init` is one-shot and the coupon is single-use. A page reload costs a FULL re-handshake — the coupon is single-use and already spent — so your backend must be prepared to mint a fresh one on every frame load.
        `docs/integrations/embed.md` carries the worked example.
      tags:
        - embed
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MintEmbedHandoffRequest"
      responses:
        "201":
          description: The minted handoff, whose token value is shown only here.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmbedHandoff"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /embed/sessions:
    post:
      operationId: createEmbedSession
      summary: Exchange a handoff for an in-frame embed session.
      description: |-
        THE EXCHANGE, and the one deliberately UNAUTHENTICATED endpoint on this API: it is called BY THE EMBED FRAME with the handoff token in the body (never a bearer) and the `host_origin` the frame measured from the postMessage event. The browser-enforced admission control is `frame-ancestors` plus the absence of CORS anywhere on this API — no hostile page can frame us or read this response; the origin checks here are the server-side narrowing behind it, not a substitute for it. On success it returns an opaque session bearer (≈30-minute TTL, hashed at rest) the frame sends as `Authorization: Bearer` on subsequent calls; the authenticator resolves it as the READ-ONLY `embed` principal, which may GET only its bound scenario and that scenario's sub-resources — every write and every other case is refused.

        It NEVER creates a user: an email with no active users row is a refusal, not provisioning. EVERY failure — bad/expired/consumed handoff, an origin that is not the coupon's own, an unlisted origin, unknown/inactive/ambiguous subject, or a scenario that is not the tenant's — returns the SAME 401, so the response is not an oracle for which origins, users or cases exist.
      tags:
        - embed
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateEmbedSessionRequest"
      responses:
        "201":
          description: The minted session, whose bearer token is shown only here.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmbedSession"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /idp-connection:
    get:
      operationId: getIdpConnection
      summary: How this tenant federates sign-in — its identity-provider connection and claimed email domains.
      description: |-
        THE ONE DOCUMENT that says how this tenant's people sign in: the identity provider it federates from (if any), the email domains it has claimed, and — in `capabilities` — what this deployment is actually able to do. Admin-role only (user principals holding `admin` or `platform`; API keys are denied regardless of scope, the same posture as /tenant-settings and /api-keys, because federation configuration is an accountable human matter).

        WHAT YOU CAN CHANGE YOURSELF, AND WHAT IS STILL DONE WITH US. `PATCH /v1/idp-connection` changes the ADMISSION SETTINGS of a connection that already exists — its display name, whether automatic admission is on, the role it grants, and whether it is available at sign-in at all — and `POST /v1/idp-connection:verify` asks your identity provider whether that connection is still configured the way it was registered. REGISTERING a connection in the first place, ROTATING its client secret, and CLAIMING a domain are not on this API yet: we do those with you, and this endpoint is how you read back what exists. Contact your LendArch representative (or support@lendarch.com) and we will register it with you; we will tell you how to send the client secret. DO NOT put a client secret in a request to this API — no endpoint here accepts one, and there is no field for it on any schema. So there is no POST and no PUT at these paths today, and no DELETE — do not write a client that expects one to appear. (DELETE is absent for a second reason as well: removing a connection is part of deprovisioning, which has its own unshipped surface. See `status` on the connection schema, which is NOT an off-switch for access.)

        WHAT A CONNECTION IS. A connection is your organization's own identity provider — Entra ID, Okta, Google Workspace, anything speaking OIDC — registered so that your people can sign in with the credentials they already have. We hold no password for them; your provider asserts who they are and we trust that assertion for the accounts you have authorized. FEDERATION IS ADDITIVE AND NEVER EXCLUSIVE: a tenant with a connection may still hold ordinary local accounts, and the absence of a connection is a permanent, fully-supported state rather than an unconfigured one. So `connection` being ABSENT here is a normal answer that means "this tenant signs in locally" — it is never an error, and this endpoint returns 200 either way.

        WHAT A CLAIMED DOMAIN DOES, AND WHAT IT DOES NOT. Claiming `acme.example` means somebody typing an unrecognized address at that domain is routed to YOUR sign-in screen and offered YOUR provider instead of a generic prompt. IT GRANTS NO ACCESS AT ALL. Read no row in `claimed_domains` as an authorization record: a claim routes a login screen, and whether a person actually gets an account is decided separately, by an invitation or by the just-in-time rule below. A domain is claimed by at most one tenant globally, which is why we verify control of it before recording the claim.

        THE ORDER THINGS HAPPEN IN, end to end, for an integrator who never opens the UI. (1) You send us your provider's connection details and we register the connection — it appears here with `status: active`. (2) You tell us which email domains are yours, and for each one we issue a DNS TXT challenge at a name under that domain; you publish that record. (3) We re-check it, and once control is confirmed the domain appears here with `verified: true` and a `verified_at` stamp. Steps 2 and 3 are where the waiting is: nothing below `verified: true` routes anything. (4) Your people sign in, and what happens to somebody we have never seen before depends on one setting you control. With `jit_enabled: false` — the default — every account is created by an explicit invitation (`POST /v1/invitations`), and an unrecognized arrival is refused with `urn:ug:error:not_admitted`: that means "we reached a decision and it was no", never "something broke", and the fix is to invite them. With `jit_enabled: true`, somebody arriving THROUGH your connection whose address is at a `verified: true` domain is admitted automatically on first sign-in, at `jit_default_role`. Both halves are load-bearing: a person who merely has a password in your organization is refused even at a verified domain, because your consent was "admit the people my identity provider asserts", not "admit anyone whose address ends the right way".

        BEFORE YOU TURN AUTOMATIC ADMISSION ON, read `capabilities`. If `domain_admission_available` is false, this deployment cannot perform the check step (4) relies on, and `jit_enabled: true` will admit nobody — your people would be refused with `not_admitted` while your own screen showed automatic admission switched on. `PATCH` still accepts the change so that your intent is recorded, but treat a false capability as "not yet" and talk to us.

        HOW A USER SEES THEIR OWN SIDE OF THIS. /v1/tenants/me's `auth` object tells the CALLER whether their own credentials are local or federated, and names the connection that asserted them. That endpoint is open to every member; this one is admin-only.
      tags:
        - idp-connection
      responses:
        "200":
          description: The tenant's federation configuration. Always 200 for an authorized caller — a tenant with no connection and no claimed domains is a normal state, reported as an absent `connection` and an empty `claimed_domains`, never as a 404.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdpConnectionConfig"
              examples:
                local_only:
                  summary: A tenant that does not federate (the common case)
                  description: "`connection` is ABSENT and `claimed_domains` is empty. Nothing is wrong and nothing is pending: this tenant's people sign in with local accounts. An admin screen should render this as \"not configured\", never as an error."
                  value:
                    claimed_domains: []
                    capabilities:
                      domain_admission_available: true
                      self_serve_registration_available: true
                      self_serve_provisioning_available: true
                federated:
                  summary: A configured connection with one verified domain and one still waiting
                  description: "`northwind.example` is verified, so an unknown address there routes to this tenant's own sign-in screen. `northwind-labs.example` is claimed but NOT verified — the DNS TXT record the provider asked for has not been seen yet, so it routes nothing. `verified_at` is absent on that one rather than null. Note `jit_enabled: false`: this tenant provisions every account explicitly."
                  value:
                    connection:
                      id: 018f3a5c-1c2e-7c9b-9f10-2a3b4c5d6e7f
                      name: Northwind Entra ID
                      kind: oidc
                      status: active
                      jit_enabled: false
                      jit_default_role: viewer
                      created_at: 2026-05-04T09:15:22Z
                      updated_at: 2026-05-04T09:15:22Z
                    claimed_domains:
                      - id: 018f3a5c-2d40-7a11-8c33-51d6e7f8a9b0
                        domain: northwind.example
                        verified: true
                        verified_at: 2026-05-04T09:41:08Z
                        created_at: 2026-05-04T09:20:11Z
                      - id: 018f3a5c-3e51-7b22-9d44-62e7f8a9b0c1
                        domain: northwind-labs.example
                        verified: false
                        created_at: 2026-05-06T14:02:44Z
                    capabilities:
                      domain_admission_available: true
                      self_serve_registration_available: true
                      self_serve_provisioning_available: true
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    put:
      operationId: registerIdpConnection
      summary: Register this workspace's identity provider, so your people can sign in with your own credentials.
      description: |-
        THE FIRST STEP OF SINGLE SIGN-ON, and the only one that needs anything from your identity provider. You create an OIDC application there, you send us its issuer URL and client credentials here, and we register it with our sign-in service and put it on your workspace's sign-in screen in one call. Admin-role only, user principals only (API keys are denied at every scope, as everywhere on this resource).

        WHAT WE DO WITH THE CLIENT SECRET, because you are about to send us one. It is forwarded to our sign-in service, which is the party that needs it to complete the handshake with your provider, and it is stored NOWHERE on our side: there is no column for it in our database, no field for it on any response schema here, and it appears in no audit record and no log line. The practical consequence is the one worth planning for — WE CANNOT SHOW IT TO YOU AGAIN, and we cannot rotate it for you. If you rotate it at your provider, that is a conversation with us, because re-pointing a connection is a new registration rather than an edit.

        ONE CONNECTION PER WORKSPACE, and a second registration is a `409` rather than a replacement. That refusal is deliberate: silently re-pointing an existing connection is how a workspace's people start authenticating somewhere else without anybody deciding to. Change a connection's SETTINGS with `PATCH`; change WHERE it points by talking to us.

        WHAT THIS CHANGES AND WHAT IT DOES NOT. After a `201` your people can sign in through your provider — and nothing admits anybody yet. Somebody arriving through the connection still needs an invitation, unless you both claim their email domain (`POST /v1/claimed-domains`) and switch `jit_enabled` on. Registration routes; admission is a separate decision you make on purpose.

        IT IS NOT IDEMPOTENT AND DELIBERATELY SO, despite being a `PUT`. A repeat is the `409` above, not a silent re-create: creating a second connection at your provider on a retry would leave one nobody can see and nobody deleted.
      tags:
        - idp-connection
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IdpConnectionRegistration"
            examples:
              minimal:
                summary: Register a connection, admitting nobody automatically
                description: The recommended first call. Your people can sign in; each of them still needs an invitation, which is the safe default to start from.
                value:
                  name: Acme Entra ID
                  issuer: https://login.microsoftonline.com/8f1c.../v2.0
                  client_id: 4b2e9f7a-1c3d-4e5f-8a9b-0c1d2e3f4a5b
                  client_secret: the-secret-your-provider-issued
              with_automatic_admission:
                summary: Register and turn automatic admission on at the same time
                description: "Only useful once you have a VERIFIED claimed domain — until then `jit_enabled: true` admits nobody. Check `capabilities.domain_admission_available` on the response."
                value:
                  name: Acme Okta
                  issuer: https://acme.okta.com
                  client_id: 0oa1b2c3d4e5f6g7h8i9
                  client_secret: the-secret-your-provider-issued
                  jit_enabled: true
                  jit_default_role: viewer
      responses:
        "201":
          description: The connection is registered at our sign-in service and offered on your workspace's sign-in screen. The body is the whole federation configuration — the same document `GET` returns — so a screen can re-render from it without a second round trip. The client secret is not in it and never will be.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdpConnectionConfig"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "409":
          description: This workspace already has a connection. Nothing was created here or at our sign-in service. Change its settings with `PATCH /v1/idp-connection`, or talk to us about pointing it somewhere else.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "422":
          description: "The registration was understood and refused. The two worth naming: an `issuer` that is not an absolute `https` URL, and `platform` as `jit_default_role` — our internal operator tier, which no automatic admission may ever reach."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          description: Registration cannot be completed from here right now, and nothing was created. Either this deployment does not offer self-serve registration (in which case we will register your connection with you — contact your LendArch representative or support@lendarch.com), or our sign-in service could not be reached just now. `retry_after` says when to try again; the response deliberately does not distinguish the two, because the action is the same and the difference is about our infrastructure.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
    patch:
      operationId: updateIdpConnection
      summary: Change how this tenant's connection admits people — its name, its availability, and its automatic-admission rule.
      description: |-
        THE ONE WRITE ON THIS RESOURCE, and it changes SETTINGS on a connection that already exists. It never re-points a connection at a different identity provider and it has no field through which to try: the issuer, the client id and the client secret are not on this request, because changing which provider asserts your people is not an edit to a connection — it is a new connection, and it is a conversation with us. Admin-role only, user principals only (API keys are denied at every scope, as on the read).

        EVERY FIELD IS OPTIONAL AND ABSENT MEANS "LEAVE IT ALONE". Send only what you are changing. A request that names no field, or that sets every field to the value it already holds, is accepted and changes nothing — it is a 200 with the current document, not an error, and nothing is written or recorded for it. (That last part is deliberate: an audit trail in which "somebody changed this" appears for calls that changed nothing is a trail you cannot read.)

        THE FIELD THAT DESERVES A SECOND READ IS `jit_enabled`. Turning it on is a standing instruction to create accounts for people you have not individually named: anyone arriving through this connection at one of your VERIFIED claimed domains gets an account at `jit_default_role` on their first sign-in. It is the least-supervised door into your workspace, which is why it ships off, why `jit_default_role` defaults to the read-only `viewer`, and why `platform` — our internal operator tier — is refused here with a 422 rather than quietly ignored. Turning it OFF stops future automatic admissions and removes nobody: the accounts it already created keep working, and taking access away is a different operation on a different object.

        `status: disabled` STOPS THE NEXT SIGN-IN, NOT THE CURRENT SESSION, and it is not an offboarding tool. See the `status` field on the connection schema, which spells out what it does and does not do; the short version is that access tokens already issued keep working until they expire and no account is removed.

        WHAT TO DO AFTER A CHANGE. `PATCH` writes only our own records: it changes nothing at your identity provider and does not re-read it. If you want to know that your provider still agrees with what is recorded here, call `POST /v1/idp-connection:verify`.
      tags:
        - idp-connection
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IdpConnectionUpdate"
            examples:
              enable_automatic_admission:
                summary: Turn automatic admission on, at the read-only role
                description: From here on, somebody arriving through this connection at a verified claimed domain is admitted as a `viewer` on their first sign-in. Check `capabilities.domain_admission_available` on the response before relying on it.
                value:
                  jit_enabled: true
                  jit_default_role: viewer
              rename_only:
                summary: Correct the display name and nothing else
                value:
                  name: Northwind Entra ID (production)
              take_it_out_of_service:
                summary: Stop new sign-ins through this connection
                description: Nobody can newly sign in through the connection. Everybody it already admitted still has their account and their role, and any token already issued keeps working until it expires.
                value:
                  status: disabled
      responses:
        "200":
          description: The tenant's federation configuration after the change — the same document `GET` returns, so a screen can re-render from this response without a second round trip. Also returned unchanged when the request asked for values that were already set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdpConnectionConfig"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          description: "This tenant has no identity-provider connection, so there is nothing to change. Note the deliberate difference from `GET`, which answers 200 with an absent `connection` for the same tenant: the read reports a STATE — signing in locally is a permanent, supported one — while this request acts on an OBJECT, and acting on an object that does not exist is a 404. Registering a connection is done with us."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "422":
          description: "The change was understood and refused. `platform` as `jit_default_role` is the refusal worth naming: it is our internal operator tier and no automatic admission may ever reach it."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    delete:
      operationId: deleteIdpConnection
      summary: Remove single sign-on for this workspace.
      description: |-
        Takes the connection out of service. Nobody can newly sign in through it, automatic admission stops, and the connection is reported `disabled` from here on. It is REVERSIBLE — `PATCH /v1/idp-connection` with `status: active` turns it back on — because the record is kept, not erased.

        WHAT IT DOES NOT DO, said plainly because a reader will assume otherwise. It removes NO account: everybody this connection already admitted keeps their access and their role, and removing a person is a separate operation on a different object (`PATCH /v1/users/{userId}`). It does not cut anybody off immediately: this API verifies access tokens offline, so a token already issued keeps working until it expires (about fifteen minutes), and a session ends at its next token refresh, which fails because the connection is disabled. And it changes nothing at your identity provider — the provider still holds the registration, and fully retiring it (so its sign-in prompt disappears) is done with us. Contact your LendArch representative to complete that.

        WHY THIS IS A DISABLE AND NOT AN ERASE. The connection is the identity of everyone who signed in through it, and those records are kept for the life of the workspace, so the connection they point at is kept too — the same reason removing a person deactivates their account rather than deleting the row. The audit trail records that single sign-on was removed, and that record is permanent.

        Idempotent: removing a connection that is already disabled succeeds and writes nothing. Admin-role only, never reachable with an API key.
      tags:
        - idp-connection
      responses:
        "200":
          description: The tenant's federation configuration after removal — the same document `GET` returns, with the connection now `disabled`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdpConnectionConfig"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          description: This tenant has no identity-provider connection, so there is nothing to remove — the same object-versus-state distinction `PATCH` documents.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /idp-connection:verify:
    post:
      operationId: verifyIdpConnection
      summary: Ask the identity provider whether this tenant's connection is still configured the way it was registered.
      description: |-
        A HEALTH CHECK YOU CAN RUN, and the answer is a report rather than an exception. It reads your connection as your identity provider currently holds it and compares it against the settings it was registered with. A healthy connection answers 200 with `drift: []`. A connection somebody has changed answers 200 with entries in `drift` — a difference in configuration is a finding to act on, not a server error, so this endpoint does not fail because it found something. Admin-role only, user principals only. It writes nothing.

        WHY THIS EXISTS AT ALL, in one sentence: some of the settings a connection is registered with are account-takeover controls rather than preferences, and one of them — `automatic_account_linking` — will, if somebody switches it on, sign a stranger in AS one of your existing users on the strength of a matching email address. Nothing in this product can switch it back on your behalf, deliberately: a reconciler that quietly "fixed" it would erase the evidence that somebody turned it on. So it is reported, loudly, for a human to investigate.

        WHAT IT ASSERTS, AND WHAT IT ONLY SHOWS. It asserts the registered admission settings, that the connection still exists at your provider, and that this tenant is still bound to an organization there. It does NOT check that the issuer and client id are the ones you intended: we record no expectation for those — they are whatever your provider is — so there is nothing to compare them against. If you need those confirmed, that is a conversation with us.

        WHEN IT CANNOT ANSWER IT SAYS SO, rather than reporting health it did not establish. If your identity provider cannot be reached, this is a 503 with `urn:ug:error:unavailable` and nothing was asserted — never a 200 with an empty `drift`, which is the one answer that would be read as "all clear". It is also rate-limited per tenant, because every call reaches out to your provider; a client that polls it in a loop gets a 429 with `Retry-After`. Poll it on a screen refresh, not on a timer.
      tags:
        - idp-connection
      responses:
        "200":
          description: The comparison completed. `drift` empty means everything asserted matched; entries mean it did not, and this is still a 200.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdpConnectionVerification"
              examples:
                healthy:
                  summary: Nothing to report
                  value:
                    checked_at: 2026-08-27T14:20:11Z
                    provider_consulted: true
                    connection_available_at_sign_in: true
                    drift: []
                    unrecognized_connections: []
                drifted:
                  summary: Somebody switched automatic account linking on
                  description: "This is the finding this endpoint exists for. Treat it as an incident: until it is switched back off at your identity provider, an upstream account whose email merely matches one of your existing users can be signed in as that user."
                  value:
                    checked_at: 2026-08-27T14:22:40Z
                    provider_consulted: true
                    connection_available_at_sign_in: true
                    drift:
                      - setting: automatic_account_linking
                        expected: off
                        actual: on
                        detail: Automatic account linking is switched on at your identity provider. While it is on, somebody signing in from your provider can be attached to an existing account here purely because the email addresses match, with no proof that they control it. It was registered off and nothing here turned it on.
                    unrecognized_connections: []
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          description: This tenant has no identity-provider connection, so there is nothing to verify — the same distinction `PATCH` draws against `GET`.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          description: Too many verifications for this tenant in too short a window. Every call reaches your identity provider, so this endpoint is metered. `Retry-After` says when to try again.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          description: "Your identity provider could not be read, so NOTHING WAS ASSERTED. This is deliberately not a 200 with an empty `drift`: reporting no findings from a check that never ran is the one wrong answer here. Retry; if it persists, the problem is between us and your provider."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
  /claimed-domains:
    post:
      operationId: claimIdpDomain
      summary: Claim an email domain, so an unknown sign-in name at it comes to your workspace.
      description: |-
        THE SECOND STEP OF SINGLE SIGN-ON. Claiming a domain routes: somebody typing `sam@acme.example` at our sign-in prompt is shown YOUR sign-in screen instead of a password box. Admin-role only, user principals only.

        YOU HAVE TO PROVE YOU CONTROL THE DOMAIN, AND THAT USUALLY TAKES TWO CALLS. Send the domain; we register it with our sign-in service and answer `202` with a DNS TXT record to publish. Publish it, then send the SAME request again: we re-check, and a record that is now in place turns the second call into a `201`. Nothing is claimed until it is proved, which is why the first call records nothing on your account.

        WHY THE PROOF IS NOT OPTIONAL. A domain is claimed by AT MOST ONE workspace anywhere in this service. Without proof, the first caller to type `bigcorp.example` would hold it against BigCorp themselves — a denial of service delivered through a routing convenience. The DNS record is what makes the exclusivity safe rather than dangerous.

        RE-SENDING IS ALWAYS SAFE, AND IT NEVER CHANGES THE RECORD YOU ARE PUBLISHING. A domain you already hold answers `200` and writes nothing. A domain still waiting answers `202` with NO `challenge` — which means "the record we already gave you is still the one to publish, and we still cannot see it". That absence is deliberate: our sign-in service mints a NEW token every time one is requested and only ever accepts its latest, so re-issuing on every re-check would invalidate the record you published while DNS was still propagating. Keep the `challenge` from the first response. If you have lost it, send `regenerate_challenge: true` — which replaces the outstanding record, so publish the new value.

        CLAIMING GRANTS NOTHING. It routes a login prompt. Whether an arriving person is admitted is a separate decision: an invitation, or `jit_enabled` on your connection plus this domain verified. A workspace may hold accounts at a domain it has claimed and accounts elsewhere; the domain says nothing about any individual.

        SOME DOMAINS ARE NEVER CLAIMABLE: public email services (`gmail.com`, `outlook.com` and their kind) and public suffixes (`co.uk`, `github.io`). Subdomains ARE claimable and are separate domains from their parent — `eng.acme.example` and `acme.example` can be held by different workspaces, because that is a fact about one company's mail estate rather than a collision.
      tags:
        - idp-connection
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/IdpClaimedDomainClaim"
            examples:
              claim:
                summary: Start (or finish) a claim
                value:
                  domain: acme.example
      responses:
        "200":
          description: This workspace already claims this domain. Nothing was written here or at our sign-in service.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdpClaimedDomainClaimResult"
        "201":
          description: Proved and claimed. The DNS record was found on this call, so the domain now routes sign-ins to this workspace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdpClaimedDomainClaimResult"
        "202":
          description: |-
            Accepted and WAITING ON YOUR DNS. The domain is registered with our sign-in service and NOTHING IS CLAIMED yet. Three shapes, told apart by two fields:

            `challenge` PRESENT — a record was issued on this call. Publish it, then send this same request again once it is live.

            `challenge` ABSENT, `verified_without_proof` FALSE — still waiting on the record from an earlier response, which is still the one to publish. This is the ordinary answer to a re-check while DNS propagates, and the absence is deliberate (see `regenerate_challenge`).

            `challenge` ABSENT, `verified_without_proof` TRUE — our sign-in service already considered this domain verified, but nothing in THIS request proved it, so we will not record a claim we cannot account for. See that field's description for what to do.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdpClaimedDomainClaimResult"
              examples:
                waiting_on_dns:
                  summary: Publish this record, then send the same request again
                  value:
                    domain: acme.example
                    claimed: false
                    verified_without_proof: false
                    challenge:
                      record_type: TXT
                      record_name: _zitadel-challenge.acme.example
                      record_value: 2f7c1b9e4a8d3f60b5c2e1a7d9048f36
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "409":
          description: "This domain is already claimed. The message names nobody, and that is structural rather than discreet: this request never reads another workspace's records, so who holds it is information we do not have here. If it is genuinely yours, contact us."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "422":
          description: "The domain cannot be claimed by anybody: a public email service, a public suffix, or a name that is not a domain. The message says which."
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          description: Nothing was claimed. Either this deployment does not offer self-serve domain claiming (contact your LendArch representative or support@lendarch.com and we will claim it with you), or our sign-in service could not be reached just now. `retry_after` says when to try again.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
  /users:
    get:
      operationId: listUsers
      summary: Everyone who can reach this workspace.
      description: |-
        The tenant's own roster: one row per person with access, including suspended and deactivated ones, because the question this list answers is "who can reach this workspace, and who used to". Oldest first, cursor-paginated.

        `identity_source` is the field worth reading. `local` means the person's credentials live in the sign-in service we operate — they have a password there, and they change it through our hosted login. `federated` means their own organization's identity provider asserts them and we hold no credential for them at all. It is resolved from our own record of how the row was created, never guessed from an email domain: a tenant may hold local accounts at a domain it has also claimed, so the domain says nothing about any individual (see `GET /v1/idp-connection`).

        Readable by any member of the tenant, at any role. NOT readable with an API key, regardless of scope: who belongs to a workspace is a question about people, answered to people, and a programmatic integration has no business enumerating a customer's staff.

        A person appears here only once they have SIGNED IN at least once. An invitation that nobody has used yet is not a user and is not listed — see `GET /v1/invitations` for those.

        `email` IS PRESENT ONLY FOR ADMINISTRATORS. Every member may read the roster — who your colleagues are is not privileged information inside a workspace — but their addresses are, so the field is omitted for `reviewer` and `viewer` callers. The example below is an administrator's response.
      tags:
        - users
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of users.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserList"
              examples:
                mixed:
                  summary: A workspace with a local administrator and a federated joiner, as an ADMINISTRATOR sees it (a reviewer or viewer gets the same rows without `email`)
                  value:
                    items:
                      - id: 018f3a5c-1c2e-7c9b-9f10-2a3b4c5d6e7f
                        email: dana@northwind.example
                        display_name: Dana Okafor
                        role: admin
                        status: active
                        identity_source: local
                        created_at: 2026-04-02T11:20:05Z
                      - id: 018f3a5c-2d40-7a11-8c33-51d6e7f8a9b0
                        email: sam@northwind.example
                        display_name: Sam Vance
                        role: reviewer
                        status: active
                        identity_source: federated
                        created_at: 2026-05-11T08:02:44Z
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /users/{userId}:
    patch:
      operationId: patchUser
      summary: Change a person's access — their status, their role, or both.
      description: |-
        The workspace's own lever over who can reach it. Setting `status` to `deactivated` removes somebody's access; `suspended` pauses it with the intent of restoring it; `active` restores a paused or removed person. Setting `role` changes what they can do. Send only the fields you want to change; a field left out is left alone, and a request that asks for values already in place succeeds and writes nothing.

        HOW QUICKLY REMOVAL TAKES EFFECT — the one thing worth being precise about. Once `status` is not `active`, this API refuses the person on their very NEXT request: it re-reads their status on each call, so removal here is immediate for anything they do against us. What it cannot do is reach back into a token already in their browser at another service; but nothing of ours accepts them once they are not active. (By contrast, disabling their whole identity provider is bounded by the access-token lifetime — about fifteen minutes — because that path is verified offline. Removing the person here is the immediate lever; see `DELETE /v1/idp-connection` for the other.) Local and federated people are removed the same way and through this same endpoint — federation changes how somebody signs in, not how their access is withdrawn.

        NOT AN ERASE. Removing a person deactivates their account; it never deletes the row. The record of who had access, what they did, and when it was withdrawn is kept for the life of the workspace, and every change here is written to the audit trail.

        TWO CHANGES ARE REFUSED, to stop a workspace locking itself out. You cannot change your OWN role (a `422`) — a second administrator makes that change, so nobody can quietly escalate themselves. And you cannot remove or demote the LAST person who can still administer the workspace (a `409`) — suspend, deactivate or demote all but one administrator and the final one is held in place until somebody else is promoted.

        Admin-role only, never reachable with an API key. `platform` cannot be granted here (it is our internal operator tier), and an account that already holds it can be changed only by us.
      tags:
        - users
      parameters:
        - $ref: "#/components/parameters/UserId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserUpdate"
            examples:
              remove_access:
                summary: Remove somebody's access
                description: They can no longer sign in, effective on their next request to us. Their account and history are kept.
                value:
                  status: deactivated
              restore_access:
                summary: Restore a suspended or removed person
                value:
                  status: active
              change_role:
                summary: Promote somebody to reviewer
                value:
                  role: reviewer
      responses:
        "200":
          description: The person's account after the change.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          description: Not an administrator, an API-key credential, or an attempt to change an account we operate (`platform`).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "404":
          description: No such person in this workspace — indistinguishable from a person in another workspace, which is also a `404`.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "409":
          description: The change would leave the workspace with no active administrator — the last one is held in place until somebody else can administer.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "422":
          description: The change was understood and refused — `platform` as a role, or an administrator changing their own role.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /invitations:
    get:
      operationId: listInvitations
      summary: Who has been authorized to join this workspace, and what became of it.
      description: |-
        Every invitation this tenant has ever written, oldest first, cursor-paginated — pending, used, revoked and lapsed alike. A pending-only view could not answer the question an administrator actually has, which is usually "I invited someone last week and they say they cannot get in".

        Read `status` rather than the timestamps: it collapses the four timestamps into the one word that decides what to do next. Read `provisioning.status` beside it to answer the other question an administrator has — has this person been emailed, or does somebody need to tell them — and read `capabilities.self_serve_provisioning_available` on `GET /v1/idp-connection` (same admin-only tier) to know which of those two sentences your deployment can even make. Pass `?status=` to page only invitations in that state — an administrator hunting the one pending invitation among a year of accepted ones asks for `status=pending` rather than paging the history. The filter is evaluated with the same derived precedence the `status` field carries (a used invitation reads `accepted` even after its expiry passes), against one server-side instant per page, so it never disagrees with the `status` on the rows it returns.

        Admin-role only, and never reachable with an API key — see `POST /v1/invitations`.
      tags:
        - invitations
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          required: false
          description: Restrict the page to invitations in one derived state. Omit for every invitation regardless of state.
          schema:
            type: string
            enum:
              - pending
              - accepted
              - revoked
              - expired
      responses:
        "200":
          description: A page of invitations.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvitationList"
              examples:
                lifecycle:
                  summary: One pending, one already used, one that lapsed
                  value:
                    items:
                      - id: 018f3a5c-4f62-7c33-ae55-73f8a9b0c1d2
                        email: sam@northwind.example
                        role: reviewer
                        status: pending
                        invited_by: 018f3a5c-1c2e-7c9b-9f10-2a3b4c5d6e7f
                        expires_at: 2026-05-18T08:00:00Z
                        provisioning:
                          status: none
                        created_at: 2026-05-11T08:00:00Z
                      - id: 018f3a5c-5073-7d44-bf66-84a9b0c1d2e3
                        email: dana@northwind.example
                        role: admin
                        status: accepted
                        invited_by: 018f3a5c-1c2e-7c9b-9f10-2a3b4c5d6e7f
                        expires_at: 2026-04-09T11:00:00Z
                        consumed_at: 2026-04-02T11:20:05Z
                        consumed_by: 018f3a5c-1c2e-7c9b-9f10-2a3b4c5d6e7f
                        provisioning:
                          status: provisioned
                          idp_account_created_at: 2026-04-02T11:00:02Z
                        created_at: 2026-04-02T11:00:00Z
                      - id: 018f3a5c-6184-7e55-c077-95b0c1d2e3f4
                        email: leaver@northwind.example
                        role: viewer
                        status: expired
                        invited_by: 018f3a5c-1c2e-7c9b-9f10-2a3b4c5d6e7f
                        expires_at: 2026-05-01T09:00:00Z
                        provisioning:
                          status: provisioned
                          idp_account_created_at: 2026-04-24T09:00:03Z
                        created_at: 2026-04-24T09:00:00Z
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    post:
      operationId: createInvitation
      summary: Authorize one email address to join this workspace at one role.
      description: |-
        WHAT AN INVITATION DOES. It records, before the fact, that a named address may become a user of this tenant at a named role. Nothing happens to it until that person signs in; at their first sign-in we ask your identity provider for their email address, and if the provider reports that address VERIFIED and it matches a pending invitation, the user is created at the invitation's role and the invitation is marked accepted. Until then no account exists here, and an unknown sign-in is refused — access is never a side effect of authenticating.

        WHAT IT DOES NOT DO, stated plainly because each one is a question integrators ask.

        It is NOT a link and it carries no token. There is nothing secret in the response and nothing for you to forward. An invitation qualifies somebody the identity provider has already authenticated; a secret link would instead admit whoever held the URL, which is the property this design exists to avoid.

        WHETHER WE EMAIL THEM DEPENDS ON THE DEPLOYMENT, and `capabilities.self_serve_provisioning_available` on `GET /v1/idp-connection` is the field that says which — read it before writing any copy about what happens next.

        TRUE: creating this invitation ALSO creates the person a sign-in account with no password, and the sign-in service emails them its own "set your password" link. The response comes back with `provisioning.status: pending` — the email is sent off the request, so RE-READ the invitation (`GET /v1/invitations/{invitationId}` or the list) to see it become `provisioned`, or `failed` with a reason. `POST /v1/invitations/{invitationId}/resend` sends it again once it is `provisioned`.

        FALSE: `provisioning.status` is `none` and nothing is emailed. Telling the person they have been invited is your message to write, and somebody who needs a password-based account in the sign-in service we operate gets one from us directly — ask, and we create it. That is the original posture and it remains a supported, healthy configuration.

        EITHER WAY THE INVITATION IS THE AUTHORIZATION, and that is the part that does not vary. It admits the person it names at their first sign-in with a verified matching address whatever `provisioning` says — so a `failed` provisioning is not a failed invitation, and somebody your identity provider already asserts needs no account from us at all.

        FOR FEDERATED AND LOCAL PEOPLE ALIKE. The same invitation works for both, and you do not say which. What differs is only who holds their password.

        LOCAL VS FEDERATED IS NOT A DOMAIN QUESTION. A claimed email domain routes a login prompt and grants nothing (`GET /v1/idp-connection`), so inviting `someone@a-domain-you-claimed.example` does not make them federated and inviting somebody at another domain does not stop them being so.

        IDEMPOTENT BY THE DATA, NOT BY A HEADER. At most one PENDING invitation can exist per address in a workspace, so retrying is safe however you retry. Re-posting the same address at the same role returns the pending invitation with `200` and changes nothing. Re-posting it at a DIFFERENT role is a `409` — we will not quietly ignore the role you asked for, and we will not quietly raise a pending one. Re-posting an address whose invitation has LAPSED withdraws the lapsed one and issues a fresh one, with `201`. `Idempotency-Key` is honoured as well, and replays the exact response.

        Admin-role only. Not reachable with an API key at any scope: an invitation grants human access to a customer's data, and a credential that could mint one could widen its own reach. `platform` cannot be granted here — it is our internal operator tier, and the database refuses the value independently of this endpoint.
      tags:
        - invitations
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateInvitationRequest"
            examples:
              reviewer:
                summary: Invite a colleague as a reviewer for the default week
                value:
                  email: sam@northwind.example
                  role: reviewer
              short_lived_admin:
                summary: A second administrator, with a two-day window
                value:
                  email: dana@northwind.example
                  role: admin
                  expires_in_days: 2
      responses:
        "200":
          description: An identical pending invitation already existed and is returned unchanged. Nothing was written.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invitation"
        "201":
          description: The invitation was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invitation"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "409":
          description: |-
            The invitation was NOT written, for one of three reasons the `detail` names.

            This address already has a PENDING invitation at a different role — revoke it and invite again. Or the address already belongs to a member of this workspace, who cannot be invited to something they already have.

            Or THIS WORKSPACE IS NOT FINISHED BEING SET UP. Where the deployment creates sign-in accounts, creating an invitation also creates the person one, and until the workspace's setup is complete on our side there is nowhere to create it — so the invitation is refused here rather than written and left waiting for an email that could never be sent. Read `capabilities.self_serve_provisioning_unavailable_reason` on `GET /v1/idp-connection` BEFORE drawing the form: it reads `workspace_setup_incomplete` for exactly this state, so an administrator can be told before they type an address rather than after. The remedy is not self-serve — contact your LendArch representative or support@lendarch.com.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /invitations/{invitationId}:
    get:
      operationId: getInvitation
      summary: One invitation.
      description: Admin-role only. An invitation belonging to another tenant is reported `404`, indistinguishable from one that does not exist.
      tags:
        - invitations
      parameters:
        - $ref: "#/components/parameters/InvitationId"
      responses:
        "200":
          description: The invitation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invitation"
              examples:
                pending:
                  summary: A pending invitation
                  value:
                    id: 018f3a5c-4f62-7c33-ae55-73f8a9b0c1d2
                    email: sam@northwind.example
                    role: reviewer
                    status: pending
                    invited_by: 018f3a5c-1c2e-7c9b-9f10-2a3b4c5d6e7f
                    expires_at: 2026-05-18T08:00:00Z
                    provisioning:
                      status: provisioned
                      idp_account_created_at: 2026-05-11T08:00:04Z
                    created_at: 2026-05-11T08:00:00Z
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    delete:
      operationId: revokeInvitation
      summary: Withdraw an invitation nobody has used yet.
      description: |-
        Revocation, not deletion: the row survives, marked `revoked`, and keeps answering "who authorized this, and when did that stop". The revoked invitation is returned.

        AN ACCEPTED INVITATION CANNOT BE REVOKED — that is a `409`. Once somebody has signed in, the invitation is the permanent record of who authorized them, and marking it withdrawn would make that record false. Removing their access is a different operation on a different object: the user. Revoking a pending invitation removes nothing from anybody, because nothing has been granted yet.

        Revoking twice is a `409` rather than a silent success, so the stored instant keeps answering "when was this withdrawn" with the first decision rather than the latest button press.

        Admin-role only, never reachable with an API key.
      tags:
        - invitations
      parameters:
        - $ref: "#/components/parameters/InvitationId"
      responses:
        "200":
          description: The invitation, now revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invitation"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          description: Already revoked, or already used to admit a user (the `detail` says which).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /invitations/{invitationId}/resend:
    post:
      operationId: resendInvitation
      summary: Send the invited person's sign-in email again.
      description: |-
        Asks the sign-in service to re-send the "set your password" email it already sent when this invitation was created. Nothing about the invitation changes: same authorization, same role, same expiry.

        WHEN TO USE IT. The colleague says they never got the email, or they let the link go stale. One call, no new invitation, and the address stays the one an administrator already authorized — re-inviting instead would revoke and replace a perfectly good authorization to solve a mail problem.

        IT REQUIRES AN ACCOUNT TO ALREADY EXIST. `provisioning.status` must be `provisioned`; anything else is a `409` that says so. That refusal is deliberate and it is the point of the route: answering "sent!" for a mail that could not have gone is the one outcome that makes things worse than doing nothing, because the administrator stops chasing while the colleague keeps waiting.

        IF THEY HAVE ALREADY SET A PASSWORD, this still answers `202` — and nothing is emailed, because there is nothing to initialize. The sign-in service is the authority on that and it refuses to re-send to somebody who has finished. In practice this is good news: they can sign in.

        That `202` covers a NARROW window, and it is worth knowing which: somebody who has set a password but has not yet signed in HERE. Once they sign in, the invitation is consumed, `status` becomes `accepted`, and this route answers `409` instead — the invitation is no longer live. So a `202` that emails nothing means "they are ready, they just have not arrived"; the thing to watch after it is `status` becoming `accepted`, not whether the mail landed.

        202, NOT 200. The send happens asynchronously, off the request. The body is the invitation as it stands right now, so `provisioning.status` still reads `provisioned` — it is not a receipt for the email.

        Admin-role only, never reachable with an API key — the whole `/v1/invitations` surface's rule.
      tags:
        - invitations
      parameters:
        - $ref: "#/components/parameters/InvitationId"
      responses:
        "202":
          description: The re-send has been queued. The body is the invitation, unchanged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invitation"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          description: |-
            Either there is no sign-in account for this invitation to send anything about, or the invitation is no longer live, or this workspace's setup is no longer complete on our side.
            DO NOT BRANCH ON `detail` — it is prose for a human. The first two conditions are readable from the invitation itself, which is also the client-side gate for whether to draw the button at all: `provisioning.status !== 'provisioned'` (nothing to resend) and `status !== 'pending'` (used, withdrawn or lapsed). The third is `capabilities.self_serve_provisioning_unavailable_reason: workspace_setup_incomplete` on `GET /v1/idp-connection`. Check those and the `409` becomes a race you never normally hit.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          description: |-
            This invitation's email was sent very recently — wait and try again. `retry_after_seconds` on the problem document is the remaining time, computed from when it actually went rather than from a fixed constant, so honouring it lands you just past the window.
            WHY THE LIMIT EXISTS: the email is sent by the sign-in service, to a real person's inbox, on our sending reputation — so a held-down button or a retry loop is somebody else's mailbox filling up. It is per-INVITATION, so it never blocks resending to a different colleague. Disable the button while the window is open rather than catching this. THE WINDOW IS TWO MINUTES from the last send, so a client can disable the action locally for 120 s after a `202` and never see this response. Withdrawing the invitation and re-inviting the address issues a fresh invitation with its own clean window.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          description: This deployment does not create sign-in accounts, so there is nothing to re-send. Read `capabilities.self_serve_provisioning_available` on `GET /v1/idp-connection` before offering the action.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
  /scenarios:
    post:
      operationId: createScenario
      summary: Create a scenario.
      description: Creates the scenario row in `created` status. `name` is required (the case's human identity); `external_ref` stays the optional tenant reference; an optional `question_set_id` attaches the case's own question set (404 when it names no question set in the caller's tenant). Documents are attached afterward via the two-phase upload nested under it (`POST /scenarios/{id}/documents`) — there is no separate "finalize intake" call; ingestion is per-document, and the scenario's aggregate status falls out of each document's finalize.
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateScenarioRequest"
            examples:
              named_case:
                summary: A case with a tenant reference, no question set yet
                value:
                  name: Q3 diligence — Riverbend
                  external_ref: CASE-2026-0417
              with_question_set:
                summary: A case that attaches its own question set at creation
                value:
                  name: Riverbend expansion
                  question_set_id: 018f3a5c-2d1e-7b44-8c55-9e0f1a2b3c4d
      responses:
        "201":
          description: The created scenario, in `created` status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Scenario"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listScenarios
      summary: List scenarios.
      description: |-
        Newest first by default (`sort=newest`, `id` DESC; ids are UUIDv7, so that is creation order), cursor-paginated. The DEFAULT ordering is part of the contract: at the default page size a client that reads only the first page must still see the tenant's most recent scenarios. `sort=oldest` reverses it for callers that explicitly ask; both directions page over the same opaque keyset cursor. `q` narrows the page to scenarios whose `name` OR caller-facing reference contains the given substring, case-insensitively (`external_ref`, falling back to the scenario id for rows that carry no `external_ref` — that is what the client renders for them). `derived_from_scenario_id` narrows the page to the cases started from one case (`POST /v1/scenarios/{scenarioId}/derive`) — the forward half of the lineage that `derived_from_scenario_id` records on each derived case, and the read a case page uses to name them. It matches the immediate source only.
        Every item carries its `lifecycle`, computed in the same query.
        A BOUNDED prefix of the page's dormant rows (ingestion finished, never run — newest first, at most 5 per response regardless of page size) additionally carries `underwriting_readiness`, derived fresh at read time. Every other row omits it; an omitted `underwriting_readiness` means "not computed", never "nothing in the way". The cap is what keeps this endpoint's no-fan-out contract true: the extra work is constant per response, not per row.
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioStatusFilter"
        - $ref: "#/components/parameters/ScenarioDerivedFromFilter"
        - $ref: "#/components/parameters/SearchQuery"
        - $ref: "#/components/parameters/SortOrder"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of scenarios.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScenarioList"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}:
    get:
      operationId: getScenario
      summary: Get a scenario.
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
      responses:
        "200":
          description: The scenario.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Scenario"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    patch:
      operationId: updateScenario
      summary: Edit a scenario's mutable properties.
      description: "`name` and `question_set_id` only — status is derived and everything else is set at creation. PATCH semantics: an omitted field is unchanged; at least one field is required. `question_set_id` must name a question set in the caller's tenant (404 otherwise) and cannot be cleared, only changed. Every update emits a `scenario.updated` audit event. reviewer/admin (or scenarios:write API key) only."
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateScenarioRequest"
      responses:
        "200":
          description: The updated scenario.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Scenario"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/derive:
    post:
      operationId: deriveScenario
      summary: Derive a new case from this one, bound to a different program.
      description: |-
        Switching a case to a different PROGRAM creates a NEW case rather than editing this one — because this case's documents were ingested under its program and its answers were produced under it, so a re-route would retroactively invalidate both. That is only tolerable because switching costs nothing: this call gives the new case THIS case's documents and their finished parse artifacts, so nothing is re-uploaded and nothing is re-parsed.

        WHAT THE NEW CASE GETS. Every document of this case that has bytes — a document still awaiting its upload, or whose upload failed, carries none and is skipped, so the two cases' document COUNTS can legitimately differ. Each one is copied: a new document row per document (created by YOU, the deriving principal, not by whoever uploaded the original), its bytes copied server-side to the new case's own storage keys, and its immutable parse artifacts (layout blocks, structured facts, page renders) and its segmentation and classification copied under it. The copy is a copy, never a share — each case's documents must be independently erasable, so erasing a document in either case leaves the other's evidence and citations untouched. `parser_version` is preserved: this is the same parse, re-pointed, not a new one.

        WHAT IT DOES NOT GET. Evaluations, decisions, decision reviews, answers and their overrides, and reports are NOT copied — the new case starts un-run. Neither is human review state: a classification a reviewer CONFIRMED lands as unreviewed, because nobody has reviewed this new case, while a classification still marked needs-review stays that way rather than being laundered clean.

        The new case keeps this case's vertical (a program lives inside a vertical, never across them), takes `program` from the request, and records this case as its source in `derived_from_scenario_id` — so the cases derived from one source are found by listing `GET /v1/scenarios` and filtering on that field; there is no separate lineage read. The switch is on both cases' audit trails (`scenario.derived` here, `scenario.created` there).

        `name` and `external_ref` are INHERITED from this case unless the request overrides them. Inheriting the reference is honest for a program switch, but override it when `external_ref` is the identifier your own system keys on: two of our cases would otherwise answer to one of yours.

        COST, AND WHY YOU WANT AN IDEMPOTENCY KEY. This call is SYNCHRONOUS and its work is proportional to the case: roughly two object copies per document (the original and its parsed artifact) plus one per rendered page, run four at a time, all inside the 60-second handler deadline. A small case returns in well under a second; a case with many large, many-page documents gets a slow 201 or, past the deadline, a timeout — and a retry WITHOUT `Idempotency-Key` copies the whole evidence set again into a SECOND derived case, leaving you two cases and no way to tell which one anyone has since worked on. Send an `Idempotency-Key`: with one, a retry replays the original response.

        Deriving from a case that has already been evaluated (even a `completed` one) is allowed — no evaluation, decision or answer is copied, so there is nothing to invalidate; the new case simply starts un-run.

        ACCEPTING A ROUTING PROPOSAL. This is the door a proposal from `GET /v1/scenarios/{scenarioId}/routing-proposal` is acted on: pass its `rule_index` as `routing_rule_index` and the server re-verifies the proposal before recording it, so the `scenario.derived` event carries the rule, the rule-set digest and the cited facts. It is optional and changes nothing about the derive itself — a program switch someone decided unaided is equally valid.

        WHAT "VERIFIED" MEANS, EXACTLY. The proposal is recomputed from the rules AS THEY STAND AT DERIVE TIME, and the derive is refused unless a rule still matches, it is the rule at the cited index, and it proposes the program being derived to. Those are the three stalenesses a person would recognise. They do not cover an IN-PLACE EDIT that leaves all three unchanged — the same index, still matching, still naming the same program, but saying something else — and in that case the event records the rule AS IT THEN STOOD, not as the person read it. Send `expected_rules_digest` (the proposal's own `rules_digest`) to close that: the derive is then refused unless the rule set is byte-identical to the one shown. Either way the trail says which, because the routing block carries `digest_asserted`.

        422 when `program` equals this case's — derive is how a case MOVES to another program, not how a case is duplicated — when `question_set_id` names a set of a different vertical or program than the new case, when a stated `routing_rule_index` no longer matches this case's facts or proposes a different program, when a stated `expected_rules_digest` is not the digest of the rules in force, and when `expected_rules_digest` arrives without a `routing_rule_index` to pin. 409 in three distinguishable cases, told apart by the Problem's `reason`: `parse_in_flight` while any of this case's documents is still parsing (it is the FINISHED parse that gets carried — retryable, and it clears itself), `document_erased` when any of them has had its content erased (the bytes are destroyed, so this case can NEVER be derived — do not retry), and `source_changed` when this case's document set moved while the copy was running (nothing at all was created — retry now). 503 when the deployment has no object store configured: there is nothing to copy the bytes with.

        EVERY MACHINE-READABLE `reason` THIS OPERATION EMITS, with what it means and what a client should do:

        `parse_in_flight` (409) — a document of this case is still parsing; it is the FINISHED parse that gets carried. Wait and retry; it clears itself.

        Do NOT retry: this case can never be derived.

        `source_changed` (409) — the case's document set moved while the copy ran. NOTHING was created. Retry now.

        `routing_proposal_stale` (422) — the proposal you cited is no longer the proposal the server computes: no rule matches now, a different rule sits at that index, that rule names a different program, or the `expected_rules_digest` you pinned is not the digest of the rule set in force (the rules were edited since the person read them). Re-read `GET /v1/scenarios/{scenarioId}/routing-proposal` and act on the current answer, or omit the field to switch without citing a rule. This is the one reason that discriminates a 422 rather than a 409 — without it, the program-mismatch shape is indistinguishable from the ordinary same-program refusal above, which carries the same code and the same `/program` pointer.

        A 422 with NO `reason` is one of this endpoint's ordinary validation refusals (same program, a cross-vertical or cross-program question set, a malformed field) and its `errors[]` pointers say which. reviewer/admin (or scenarios:write API key) only.
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeriveScenarioRequest"
            examples:
              switch_program:
                summary: Move this case to another program, keeping its documents
                value:
                  program: conventional
              switch_program_and_bind:
                summary: Switch program and bind the new case's rulebook in one call
                value:
                  program: conventional
                  question_set_id: 018f3a5c-2d1e-7b44-8c55-9e0f1a2b3c4d
                  name: Riverbend expansion (conventional)
      responses:
        "201":
          description: The NEW case, in the status its copied documents imply (a case whose documents all parsed cleanly is immediately ready to run). The source case is unchanged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Scenario"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/routing-proposal:
    get:
      operationId: getScenarioRoutingProposal
      summary: Which program the tenant's selection rules PROPOSE for this case.
      description: |-
        Computes, on demand, what the tenant's ordered program selection rules (`routing` in `PUT /v1/tenant-settings`) propose for THIS case, from the facts this case has actually promoted. The rules are evaluated in order and the first whose every condition holds decides; the response names that rule's position and CITES the facts it matched on — canonical key, value, and the fact row, its document and its source path — so a reviewer sees the reasoning rather than a bare verdict.

        A PROPOSAL IS NOT A DECISION, AND THIS READ CHANGES NOTHING. Nothing is persisted, nothing is bound, no audit event is written, and no program moves: a case's program is immutable and there is no proposal to "clear" or "dismiss" — the answer is recomputed from the case's facts every time you ask, so it simply changes as the evidence does. Acting on a proposal is a human act at one of two doors: create the case with the program (`POST /v1/scenarios`), or, for a case that already exists, derive a new one (`POST /v1/scenarios/{scenarioId}/derive`), which accepts an optional `routing_rule_index` so the audit trail records WHICH rule the person followed.

        No LLM participates. Routing contributes to which rulebook judges a case, and everything on that path is deterministic and citation-backed.

        200 WITH `matched: false` IS THE ORDINARY ANSWER, and TWO counts tell the cases apart. `rule_count` separates "no rules are configured" (`0` — the shipped default; the UI shows no routing affordance and manual selection remains fully sufficient) from "the rules ran". Among the runs, `facts_considered` explains a non-match: `0` means this case has promoted no facts yet, so no rule COULD have matched — a different thing to tell a person than "the rules ran against this case's evidence and none of them fit", which is a non-zero `facts_considered` with `matched: false`. It is also how a rule set naming a canonical key nothing promotes becomes visible instead of mysterious. None of these is an error and none is a 404.

        FACTS OF AN ERASED DOCUMENT NEVER ROUTE AND ARE NEVER CITED. A record content erasure destroys a document's content while keeping its rows — a fact's `canonical_key` survives and its `value` becomes the tombstone marker — so the facts of an erased document are dropped from the engine's input entirely and are NOT counted in `facts_considered`. An `exists` condition therefore stops holding on erased evidence rather than going on firing on it, and no proposal is ever grounded in a citation whose content is gone. It also keeps this read honest with the act it feeds: a derive on a case holding an erased document is refused 409 `document_erased`, so a proposal citing one would be a proposal nobody could accept.

        COMPUTED ON DEMAND, AND A READ-ON-OPEN RATHER THAN SOMETHING TO POLL. Nothing here is cached or precomputed: a call re-reads the case, the tenant's rules, ALL of the case's promoted facts (the engine is given the whole set, not a filtered slice), the case's documents to find any erased one, and — only when a rule matched — its vertical's canonical-key labels. Cost is therefore proportional to how many facts the case has promoted, which grows with its documents; a case with no rules configured costs one settings read and stops there. Read it when a person opens the case or reaches the program affordance, and re-read it after an upload, a re-parse or a rules change. Polling buys nothing: the answer moves only when the evidence or the rules move, and it never moves on its own.

        Any authenticated tenant member may read.
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
      responses:
        "200":
          description: The proposal, or the honest "nothing proposed" answer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RoutingProposal"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          description: An embed session — the tenant's selection rules are not its to see.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          description: 'This deployment has no routing service wired — explicitly NOT "no rules are configured" (which is a 200 with `rule_count: 0`). A client must not render it as an unconfigured tenant.'
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
  /scenarios/{scenarioId}/overview:
    get:
      operationId: getScenarioOverview
      summary: Get the composed case overview — one workflow-shaped read for the case-detail surface.
      description: "Composes what the case-detail page previously stitched from six resources: the scenario itself (full shape, lifecycle included), the document inventory MERGED with the question-derived completeness expectation (per expected doc type: the evidence documents present, or a ghost row for a missing one), the latest underwriting run with its pinned set's name/version and — while in flight — its staged progress, and a small \"needs you\" attention list (decision awaiting review, failed documents, rule changes awaiting confirmation). One handler, batched per-scenario reads, no N+1. Document expectations derive from the case's OWN question set's latest published version; a case with no set — or a set with no published version yet — simply has no `expected` list, never a 404: the overview is the surface a half-set-up case renders from too. Any authenticated tenant member may read."
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
      responses:
        "200":
          description: The composed case overview.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScenarioOverview"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents:
    post:
      operationId: createScenarioDocument
      summary: Initiate a document upload under a scenario.
      description: "Two-phase upload, phase one: validates the filename, content type (file-type allow-list), and declared size, then creates the document row — scoped to this scenario, 404 if it does not exist in this tenant — in `pending` status and returns a presigned PUT URL. Optional uploader context (`declared_type`, `declared_description`, `declared_party`) is persisted on the document record and seeds classification."
      tags:
        - scenarios
        - documents
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateDocumentRequest"
      responses:
        "201":
          description: Document created in `pending` status, with a presigned upload URL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateDocumentResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "413":
          $ref: "#/components/responses/Problem"
        "415":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listScenarioDocuments
      summary: List a scenario's documents.
      description: Rows plus parse status.
      tags:
        - scenarios
        - documents
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of documents.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents/{documentId}:
    get:
      operationId: getScenarioDocument
      summary: Get a document.
      tags:
        - scenarios
        - documents
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DocumentId"
      responses:
        "200":
          description: The document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Document"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents/{documentId}/complete:
    post:
      operationId: completeScenarioDocument
      summary: Finalize a document upload.
      description: "Phase two of the upload: verifies the uploaded object server-side (actual size, sha256, and — for content types whose magic bytes are checkable — that they match the declared content type) and transitions the document from `pending` to `uploaded` or `failed`. A same-tenant document already sharing the computed sha256 is reported as `duplicate_of_document_id` (informational only; both documents remain distinct rows)."
      tags:
        - scenarios
        - documents
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DocumentId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CompleteScenarioDocumentRequest"
      responses:
        "200":
          description: The finalized document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompleteDocumentResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "413":
          $ref: "#/components/responses/Problem"
        "415":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents/{documentId}/download:
    get:
      operationId: getScenarioDocumentDownload
      summary: Get a presigned download URL for an uploaded document.
      description: Any authenticated member of the tenant may download (viewer role included). Only `uploaded` documents can be downloaded. 409 with reason `document_erased` once the record's content has been erased — the bytes are destroyed and no download URL is minted.
      tags:
        - scenarios
        - documents
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DocumentId"
      responses:
        "200":
          description: A presigned, time-limited download URL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GetDocumentDownloadResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents/{documentId}/reparse:
    post:
      operationId: reparseScenarioDocument
      summary: Retry/reparse a document from a terminal parse state.
      description: Parse results are immutable, so a re-parse is legal only from a settled `parse_status` (`parsed`, `degraded_parse`, `failed`, or `unsupported_pending` — the case where a format was accepted and its reader has since landed) AND only while nothing cites the current parse. A parse that is already cited is frozen, and this returns `409` asking for a new document version instead. On success it resets `parse_status` to `pending` and restarts ingestion.
      tags:
        - scenarios
        - documents
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DocumentId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The document, reset to `pending` and re-queued for ingestion.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Document"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents/{documentId}/blocks:
    get:
      operationId: listScenarioDocumentBlocks
      summary: List a scenario document's layout blocks.
      description: Block IDs are the universal citation currency. Populated once the document's parse_status reaches parsed/degraded_parse; empty before that, and always empty for a structured-family document (no blocks).
      tags:
        - scenarios
        - documents
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DocumentId"
        - $ref: "#/components/parameters/PageNo"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of layout blocks, in reading order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LayoutBlockList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents/{documentId}/blocks:resolve:
    post:
      operationId: resolveScenarioDocumentBlocks
      summary: Batch-resolve a scenario document's layout block IDs to their full rows.
      description: |-
        Turn citations into page regions in ONE call. Block IDs are the universal citation currency: an answer, a flagged rule change and a supplemental question all cite a set of them, and this endpoint is how a client turns that set into the page + bbox its viewer highlights.

        A document with no layout blocks resolves every ID as missing — `items: []`, every requested ID in `missing_block_ids`, still a `200`. That covers a document whose `parse_status` has not yet reached parsed/degraded_parse, and every STRUCTURED-family document, which has no layout blocks at all; a citation into structured content is a fact path, resolved through the facts endpoint, not a block ID.

        THE LIST ENDPOINT IS NOT THE WAY TO DO THIS. Paging `GET.../blocks` looking for an id costs one request per page and cannot tell you the difference between "not in this document" and "I gave up paging" — the exact failure this endpoint was added to remove (the SPA previously scanned up to 25 pages and returned nothing at all past 5,000 blocks, which a 100-document case reaches).

        Resolution is EXPLICIT IN BOTH DIRECTIONS: `items` carries every requested id that exists, in reading order, and `missing_block_ids` names every one that does not. A caller never has to diff the two sets itself, and never has to guess whether an empty `items` meant "none of them exist" or "something went wrong". A missing id is a normal 200 outcome, not a 404 — one dangling citation among twenty good ones is not a failed request (report it, never silently approximate it).

        Scoped to ONE document, like every other read under this path: a block id belonging to a different document — or to a document in a different case in the same tenant — is reported in `missing_block_ids`, never resolved across the boundary. A `documentId` that is not this scenario's is `404`.

        Blocks carry the reading overlay (`reading_state`, `effective_text`, `reading_proposal_ids`) exactly as the list endpoint serves it, so a citation resolved here cannot disagree with the review gate about whether its passage is disputed.

        At most 200 ids per call, at least 1 — an overflow is `422`, never a truncated answer. This is a read: it records no audit event and changes nothing, so it is safely repeatable.
      tags:
        - scenarios
        - documents
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DocumentId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResolveLayoutBlocksRequest"
            example:
              block_ids:
                - 6f9619ff-8b86-d011-b42d-00c04fc964ff
                - 3f2504e0-4f89-11d3-9a0c-0305e82c3301
      responses:
        "200":
          description: The requested blocks that exist, in reading order, plus the ids that do not.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResolveLayoutBlocksResponse"
              example:
                items:
                  - id: 6f9619ff-8b86-d011-b42d-00c04fc964ff
                    page_no: 4
                    block_idx: 2
                    block_type: text
                    text: The applicant has been self-employed since March 2019.
                    bbox:
                      x0: 72
                      y0: 208
                      x1: 523
                      y1: 244
                      page_w: 612
                      page_h: 792
                    reading_order: 37
                  - id: 16fd2706-8baf-433b-82eb-8c7fada847da
                    page_no: 4
                    block_idx: 3
                    block_type: text
                    text: Gross monthly income 8,4OO
                    bbox:
                      x0: 72
                      y0: 252
                      x1: 523
                      y1: 288
                      page_w: 612
                      page_h: 792
                    reading_order: 38
                    reading_state: disputed
                    effective_text: Gross monthly income 8,400
                    reading_proposal_ids:
                      - 9d2c1f4e-6a7b-4c3d-8e5f-0a1b2c3d4e5f
                missing_block_ids:
                  - 3f2504e0-4f89-11d3-9a0c-0305e82c3301
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents/{documentId}/pages:
    get:
      operationId: listScenarioDocumentPages
      summary: List a scenario document's rendered pages.
      description: Presigned PNG render URLs + pixel dimensions for the evidence viewer to overlay block bboxes on. Populated once the document's parse_status reaches parsed/degraded_parse; empty before that, and always empty for a structured-family document.
      tags:
        - scenarios
        - documents
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DocumentId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of rendered document pages, in page order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentPageList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents/{documentId}/reading-check:
    get:
      operationId: getScenarioDocumentReadingCheck
      summary: How this document was read — the reading check's latest attempt.
      description: |-
        For a document whose text exists only because a parser read pixels, a vision model re-reads each page image against the parser's own block text and produces PROPOSALS, never edits. This returns the run record: what was checked, how much the deterministic post-filter refused, and how many proposals are open.

        ALWAYS 200 for a document the caller can read. A document that was never checked returns `status: "not_applicable"` with a `reason` explaining why (`not_ocr_derived` for a born-digital PDF or a structured file, `checker_not_configured` for a deployment with the checker off) — NOT a 404. This is deliberate and worth relying on: a derived per-document state must be a value, or every integrator writes `if 404: treat as fine` and eventually mistakes a real 404 for a clean document. A 404 here means exactly one thing — no such document in your tenant. Same posture as GET /v1/scenarios/{id}/completeness.

        STATE MACHINE. An attempt is born `running` and transitions exactly once to `completed`, `skipped` or `failed`; `finished_at` is set at that transition and the row never moves again. `not_applicable` is not a stored state — it is what this endpoint reports when no attempt row exists. A re-run APPENDS a new attempt rather than replacing one, so `attempt` is dense and 1-based per document and older attempts stay readable forever via `?attempt=N`.

        A `failed` or `skipped` attempt surfaces NO proposals: findings from a run that could not finish are not evidence a human should act on. A `completed` attempt with zero proposals is the ordinary good outcome — the reading matched the images.
      tags:
        - scenarios
        - documents
        - reading-checks
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DocumentId"
        - $ref: "#/components/parameters/ReadingCheckAttempt"
      responses:
        "200":
          description: The requested attempt, or a `not_applicable` value for a document no check applies to.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentReadingCheck"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/documents/{documentId}/reading-proposals:
    get:
      operationId: listScenarioDocumentReadingProposals
      summary: List one document's reading proposals.
      description: |-
        One page of the machine's claims about this document's reading, oldest first (`id` ascending — ids are time-ordered UUIDv7, so this is creation order). Every proposal names one block and is either a `correction` (a proposed replacement reading, one human click from being confirmed) or a `low_confidence` marker (no replacement is offered).

        Empty `items` for a document with no proposals — never a 404. A 404 here means only "no such document in your tenant". Proposals belonging to an erased document are never returned, because their text columns are tombstones and there is nothing left to confirm.

        Pagination is the house keyset scheme: pass `next_cursor` back as `cursor` until it is absent.
      tags:
        - scenarios
        - documents
        - reading-checks
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DocumentId"
        - $ref: "#/components/parameters/ReadingProposalStatusFilter"
        - $ref: "#/components/parameters/ReadingProposalKindFilter"
        - $ref: "#/components/parameters/PageNo"
        - $ref: "#/components/parameters/ReadingCheckAttempt"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of reading proposals, oldest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadingProposalList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/events:
    get:
      operationId: getScenarioEvents
      summary: Server-sent event stream for a scenario.
      description: |-
        `scenario.status_changed` and `document.ingested` frames, `id:` a UUIDv7 from `stream_events` — reconnect with the `Last-Event-ID` header to replay the gap losslessly. Any authenticated member of the tenant may subscribe. `evaluation.progress` frames carry `{evaluation_id, status, step, progress}` where `progress` is the run's RunProgress minus the spend fields (financial figures are platform-role-only and this stream is member-baseline).
        `step` names the run's position. A BOUNDARY frame names the step that just completed or the transition just written (`routing`, `completeness`, `deterministic`, `effective_set`, `delta_plan`, `panel`, `budget`, `panel_degraded`, `batch_fallback`, `resume`, `terminal`, …). `step` is an open vocabulary: switch on it if you like, but treat an unknown value as "some progress happened" and read the payload. Every frame carries the full RunProgress, so a client never needs to reconstruct state from the step names.
        Mid-stage frames are THROTTLED to at most one per second per run (a 99-unit fan-out is not 99 frames), and they are advisory: a frame that fails to write is logged and dropped rather than failing the run's billed work, so a client must still treat the polled status GET as the authority and the stream as the accelerator.
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - name: Last-Event-ID
          in: header
          required: false
          schema:
            type: string
            format: uuid
          description: Resume point; events with a greater id replay.
      responses:
        "200":
          description: An open `text/event-stream` connection.
          content:
            text/event-stream:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/inventory:
    get:
      operationId: listScenarioInventory
      summary: List a scenario's document inventory.
      description: "Logical documents: the segment- level classification result, including `classified_by`, shadow- detection fields, review flags, and `review_status`. Any authenticated tenant member may read."
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of logical documents.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LogicalDocumentList"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/inventory/{logicalDocumentId}/classification:
    post:
      operationId: classifyLogicalDocument
      summary: Human classification override for one logical document.
      description: "The human review action: sets `doc_type`/`party`/`period`, `classified_by` becomes `human`, `review_status` becomes `confirmed`; `is_directive` is re-copied from the taxonomy entry, never accepted from the request. `doc_type` must be an active entry in the tenant's taxonomy (422 otherwise). Recomputes the scenario's aggregate status in the same transaction. Idempotency-Key honored via IdempotencyMiddleware. reviewer/admin (or scenarios:write API key) only."
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/LogicalDocumentId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ClassifyLogicalDocumentRequest"
      responses:
        "200":
          description: The updated logical document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LogicalDocument"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/facts:
    get:
      operationId: listScenarioFacts
      summary: List a scenario's structured facts.
      description: Structured facts with provenance, optionally filtered to one canonical key and/or one entity_ref. Any authenticated tenant member may read.
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/CanonicalKeyFilter"
        - $ref: "#/components/parameters/EntityRefFilter"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of structured facts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StructuredFactList"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/snapshot:
    get:
      operationId: getScenarioSnapshot
      summary: Get the scenario's computed normalized-snapshot projection.
      description: Structured facts, grouped by the entity they belong to. The reference grammar — `entity:ordinal(/sub_entity:ordinal)*` — determines each entity's type, ordinal and parent; a caller wanting a nested tree rebuilds it from the parent references. The snapshot is computed on read, not stored. Any authenticated member of the workspace may read it.
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
      responses:
        "200":
          description: The scenario's snapshot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Snapshot"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/completeness:
    get:
      operationId: getScenarioCompleteness
      summary: Get the scenario's question-derived completeness preview (advisory).
      description: "This is the PRE-EVALUATION surface: expectations derive from the named question set's latest PUBLISHED version (zero-config — never from shipped rule packs), previewed AS OF NOW (pinned globals − confirmed exemptions + confirmed modifications + scenario supplemental questions — the same effective-set machinery an evaluation freezes), gate-aware against the scenario's current promoted facts, and checked against its current classified evidence. `expectations` carries ALL states (required | conditional | not_required — the client groups them; missing is derivable as state==required && !present). ADVISORY, always: this report never blocks an evaluation or anything else. 404 when the question set has no published version (the same posture as evaluate's pin resolution). Any authenticated tenant member may read."
      tags:
        - scenarios
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - name: question_set_id
          in: query
          required: true
          description: The question set to derive expectations from; its latest published version is used.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The scenario's advisory completeness report.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScenarioCompletenessReport"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/evaluate:
    post:
      operationId: evaluateScenario
      summary: Start a scenario evaluation.
      description: Pins the current published version of the question set plus the latest taxonomy revision and starts the durable ScenarioEvaluation workflow. The set is the request's `question_set_id` when given, else the scenario's own (422 when neither exists). 404 when the question set has no published version to pin.
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EvaluateScenarioRequest"
      responses:
        "202":
          description: The new evaluation run (poll it for progress, or follow the scenario's SSE stream).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Evaluation"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/reevaluation-plan:
    get:
      operationId: getReevaluationPlan
      summary: Preview what a delta re-evaluation would re-answer.
      description: "The read-only dry run of the invalidation closure: given the scenario as it stands now and the question set a run would pin, computes the SAME closure the `delta_plan` step computes and reports the would-be scope — per-lineage disposition (carry | re_answer | new | leaves_set) with the seed reasons behind each, the `scope_summary` counts, the degrade-to-full verdict, the bundle/document counts, and the delta document-metering preview (which document versions this run would meter, which the scenario has already metered). Computes NOTHING new: it persists no plan, starts no run, writes no audit event, makes no LLM call, and is safe to poll — the same inputs give the same answer. Idempotent by construction, so no Idempotency-Key (a GET carries none; the sibling `POST.../evaluate` is where the key belongs). What it does carry is the customer's own metering basis, so it is gated at the launch tier — the principals who may START a run (`scenarios:write`) are exactly the ones who may price it. 404 when the question set has no published version to pin (the same posture as evaluate); 422 on an unknown `run_scope`."
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - name: question_set_id
          in: query
          required: true
          description: The question set a run would evaluate against; its latest PUBLISHED version is pinned for the preview, exactly as evaluate would pin it.
          schema:
            type: string
            format: uuid
        - name: run_scope
          in: query
          required: false
          description: Which scope to preview. Defaults to `delta` (the only scope a preview is interesting for); `failed_flagged` additionally seeds abstained/flagged lineages; `full` is accepted and honestly reports everything re-answered.
          schema:
            $ref: "#/components/schemas/RunScope"
      responses:
        "200":
          description: The would-be closure for a run started now.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReevaluationPlanPreview"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/evaluations:
    get:
      operationId: listEvaluations
      summary: List a scenario's evaluation runs.
      description: The launch-history / status-polling surface, newest first.
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of evaluation runs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EvaluationList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/evaluations/{evaluationId}:
    get:
      operationId: getEvaluation
      summary: Get one evaluation run's status (the progress-polling resource).
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/EvaluationId"
      responses:
        "200":
          description: The evaluation's current status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Evaluation"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/evaluations/{evaluationId}/results:
    get:
      operationId: getEvaluationResults
      summary: Get one evaluation's full results (routing + completeness + node-level answers).
      description: "Everything the run has produced: the run itself, the program routing as a cited record, the completeness report read back from the run's own checkpoint rather than recomputed, and every answer so far with its node, evidence and consensus detail. `answers` is legitimately empty for a run that has not reached the answering stage yet — an empty list, not an error."
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/EvaluationId"
      responses:
        "200":
          description: The evaluation's shaped results.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EvaluationResults"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/evaluations/{evaluationId}/results/summary:
    get:
      operationId: getEvaluationResultsSummary
      summary: Get one evaluation's aggregated results summary.
      description: |-
        The judgment-first aggregation over a run's results, computed SQL-side (result sets reach thousands of answers — the client never counts pages): verdict counts over each lineage's CURRENT answer in this run's results view (the same membership as GET.../results — a delta run's carried answers included), the capped list of answers a human should actually look at with `total_attention` alongside, and per-topic groups keyed on the question model's `category`.
        "Needs review" is defined HONESTLY here, replacing the blanket per-answer review demand: an answer needs review iff its verdict is `fail`, OR it abstained (`insufficient_evidence`), OR it carries at least one UNRESOLVED review flag (answer_flags — model disagreement, unverified citations, consistency contradictions, …), OR it is decision-driving (named by a rationale entry of this run's newest decision). Everything else is presented as auto-accepted. The one flag NOT counted is the calibration-bootstrap marker, which rides on every answer until a calibration curve is fitted and would otherwise re-create the blanket review wall — it is surfaced once at the run level as `confidence_uncalibrated` instead. Any authenticated tenant member may read.
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/EvaluationId"
      responses:
        "200":
          description: The aggregated summary.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EvaluationResultsSummary"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/evaluations/{evaluationId}/effective-question-set:
    get:
      operationId: getScenarioEvaluationEffectiveSet
      summary: The scenario-nested twin of getEvaluationEffectiveSet.
      description: |-
        Byte-identical response to `GET /evaluations/{evaluationId}/effective-question-set` — the same frozen `effective-set/v1` projection, the same staleness marker — reached through the case that owns the run. An evaluation belonging to a DIFFERENT case is a 404, not another case's answer.

        WHY THE NESTED TWIN EXISTS. The read-only, single-scenario embed principal is confined by `EmbedScopeMiddleware` to GETs that carry a `{scenarioId}` matching its bound case — and that gate is deliberately "must BE scenario-scoped AND match", not "if scoped, must match", so a param-less top-level path is refused outright even when the caller could legitimately read it. The bare `/evaluations/{evaluationId}/...` shape is therefore unreachable in-frame. Rather than widen the containment, the read gets a scenario-scoped address. First-party callers may use either; the embedded SPA uses only this one ("the nested-route rule"). Member-read tier: any authenticated member of the tenant may call it — the nesting exists to give the read-only embed principal an admissible address, not to add an authorization tier.
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/EvaluationId"
      responses:
        "200":
          description: The frozen effective question set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EffectiveQuestionSet"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/evaluations/{evaluationId}/questions:
    get:
      operationId: listEvaluationQuestions
      summary: Every question of the version THIS run pinned, unpaginated.
      description: |-
        The run's own questions, in display order — the same rows and the same `AllQuestionsResponse` shape as `GET /question-sets/{questionSetId}/versions/{version}/questions:all`, but addressed by the RUN instead of by the set: the version is resolved server-side from `evaluations.question_set_version_id`, so a caller never has to know (or be trusted with) which set a historical run used.

        WHY IT EXISTS. The results endpoints name each answer only by `question_id`/`question_lineage_id`; the human-readable prompt lives on the pinned `questions` rows. Every reader of a run therefore performs this join, and until now the only address for it was the top-level question-set path — param-less, and so refused for the read-only embed principal (see getScenarioEvaluationEffectiveSet's description for the rule). Without it an embedded audit view can only label its rows "Question N", which is the exact defect existed to fix.

        UNPAGINATED, AND HERE IS THE WORST CASE. That is deliberate, for the reason `questions:all` is unpaginated too — a partial join is not a partial answer, it is a grid of rows silently labelled "Question N", which is the exact defect existed to fix. It replaces the 22-request cursor drain the app used to perform for the same data.

        An evaluation belonging to a different case is a 404. Member-read tier: any authenticated member of the tenant may call it.
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/EvaluationId"
      responses:
        "200":
          description: Every question of the run's pinned version, in display order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AllQuestionsResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /evaluations/{evaluationId}/effective-question-set:
    get:
      operationId: getEvaluationEffectiveSet
      summary: Get one evaluation's frozen effective question set.
      description: |-
        Renders the frozen `effective-set/v1` projection directly (no reconstruction joins) — the pinned global version minus effective exemptions, plus modifications, plus directive-derived supplemental questions, with per-item provenance (global | supplemental | exempted | modified). A legacy evaluation (persisted before, `{}`) is reconstructed into the identity projection (the pinned global version, no directives) so every reader sees a uniform shape. `stale` (+ `stale_reasons`) is the re-eval-needed marker: true when a directive artifact this evaluation pinned has since moved (a newer completed re-derive, or a supersession reviewed after this evaluation completed) — the effective set itself is never mutated in place; a stale evaluation's remedy is a fresh evaluate call naming it via `reevaluate_of`. Top-level path: evaluation ids are already globally unique (mirrors `/supersessions/{id}/review`'s shape).

        NOT REACHABLE BY AN EMBED PRINCIPAL — this path carries no `{scenarioId}`, and the read-only embed session is admitted only on scenario-scoped GETs. The scenario-nested twin is `GET /scenarios/{scenarioId}/evaluations/{evaluationId}/effective-question-set`, which returns the identical body. Either is fine for a user or API-key caller; an in-app caller should prefer the nested one, or the same read will work in a tab and 403 in a frame.

        Member-read tier: any authenticated member of the tenant may call it.
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/EvaluationId"
      responses:
        "200":
          description: The frozen effective question set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EffectiveQuestionSet"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /evaluations/{evaluationId}/resume:
    post:
      operationId: resumeEvaluation
      summary: Resume a paused run.
      description: "Flips a `paused_degraded` or `paused_budget` evaluation back to `running` (the guard-legal edge) and re-invokes the durable workflow under its deterministic id; `evaluation_step_checkpoint` rows guarantee zero duplicate LLM spend on re-entry. NOT a blind retry: a `paused_degraded` resume requires ≥2 family-distinct panel seats currently available (the same availability pause trigger reads), else 409 with `reason: panel_degraded`; a `paused_budget` resume RE-PINS the run's budget_snapshot from the tenant's current policy (the sanctioned recalculation point after an operator raises the budget — resuming against the original pinned snapshot would just re-pause instantly) and requires the re-pinned budget to actually have headroom now, else 409 with `reason: budget_exhausted`. Any other status is 409 with `reason: not_resumable`. Audited (`evaluation.resumed`, API actor, pause class); emits an `evaluation.progress` SSE frame on the scenario's event stream so open clients transition without polling. Top-level path: evaluation ids are globally unique (mirrors `/evaluations/{evaluationId}/effective-question-set`)."
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/EvaluationId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: The evaluation, back in `running` (poll it, or follow the scenario's SSE stream).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Evaluation"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/decisions:
    get:
      operationId: listDecisions
      summary: List a scenario's decision recomputations, newest first.
      description: A decision is a PROPOSAL until a recorded human acceptance — `latest_review` (if present) carries that state; an absent `latest_review` means the decision is still unaccepted.
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
      responses:
        "200":
          description: The scenario's decisions, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/decisions/{decisionId}/review:
    post:
      operationId: reviewDecision
      summary: Record the human acceptance/rejection sign-off on a decision (mandatory).
      description: "Append-only: a decision may be reviewed more than once; the newest row is the current state. No LLM composes the review — this is purely the human's own action, audited. Requires the reviewer or admin role: this human sign-off is never reachable by an API key."
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DecisionId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReviewDecisionRequest"
      responses:
        "201":
          description: The recorded review.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DecisionReview"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/answers/{answerId}/override:
    post:
      operationId: overrideAnswer
      summary: Override an answer's verdict, with a mandatory reason.
      description: "A reviewer overrides a current answer's verdict with a MANDATORY reason. In one transaction the prior answer is superseded (its one-shot forward pointer consumed), a new source=human_override answer becomes current with the prior answer's nodes carried forward (never auto-accepted), the override is captured as a CANDIDATE calibration label (no refit here), and the deterministic decision rollup recomputes over the fresh current-answer set into a NEW decision PROPOSAL. A recomputed decision requires fresh human acceptance — prior acceptance never carries over. The scenario's SSE stream carries `answer.overridden` + `decision.updated` so open clients see the flip live. 409 if the answer is already superseded (the override is one-shot per answer). Requires the reviewer or admin role: this is a human judgment, never reachable by an API key. Idempotency-Key honored via IdempotencyMiddleware."
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/AnswerId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OverrideAnswerRequest"
      responses:
        "201":
          description: The override result — the new current answer, the recomputed decision proposal, and the candidate-label id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OverrideAnswerResult"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/directives:
    get:
      operationId: listScenarioDirectives
      summary: List the case-specific instructions attached to a case.
      description: |-
        Every logical document in this scenario currently classified as a directive source, each with its latest extraction run's status and a coverage-ledger summary: how many blocks compiled to a supplemental question, a stated fact, or a rule-change request (supersession candidate) — and, of those rule-change requests, how many became a confirmable `question_supersessions` row versus how many the automated matcher could NOT resolve (zero deterministic candidates, the two AI reviews disagreeing, or a seat failure) and so still need a human to read the source text directly (manual escape hatch). A directive document with no extraction yet (still pending classification confirmation, or not yet started) is listed with only its id — never blocking, per the zero-config doctrine. Member-read: any authenticated principal may list this, the same tier as the scenario's other read surfaces (e.g. GET.../inventory).

        Each flagged entry carries WHAT the statement said as well as why it was left for a human: the extractor's plain-language rule (`normative_text`) and the cited block's own parsed text (`source_text`, captioned by `source_label` when it came from a named element of a structured file), so the queue reads as a list of statements rather than a count. A statement a human has dismissed as not-a-rule-change drops out of this listing and out of the case attention line; pass `includeDismissed=true` to see it with its dismissal.
      tags:
        - directives
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - name: includeDismissed
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: "Include flagged statements a human dismissed as not-a-rule-change. Default false: the listing is the work queue. The dismissed COUNT is reported either way as `coverage.supersessions_dismissed`, so a client never has to ask twice to know whether anything was dismissed."
      responses:
        "200":
          description: The scenario's directive documents.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DirectiveDocumentList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/supersessions:
    get:
      operationId: listScenarioSupersessions
      summary: List a scenario's supersessions — the directive review queue.
      description: "Lists the supersessions extracted from this scenario's directive documents, optionally filtered to one review_status. A supersession waives (exempt) or modifies a global question for this one case; it takes effect only once confirmed, and an eligibility target ALWAYS requires confirmation — the structural hard rule with no config override. Requires reviewer or admin role: a human review surface, never reachable by an API key."
      tags:
        - directives
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/SupersessionStatusFilter"
      responses:
        "200":
          description: The scenario's supersessions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSupersessionList"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    post:
      operationId: createScenarioSupersession
      summary: Create a supersession by hand from a flagged statement.
      description: "The manual escape hatch: a human creates the rule change the automated matcher could not, directly from a flagged unmatched statement. Born `pending` with `matched_by=human` and no match confidence — confirming it is a SECOND, separate action (never implicit), so the create and the confirm stay distinct audit events, and an eligibility target still has no effect until that confirm lands. Structurally bounded: every cited block must be a rule-change request the named extraction actually produced and that no supersession covers yet (422 when a block is not a rule-change request of that run, 409 when one is already covered), the target must be a GLOBAL question lineage in the question set this scenario would pin, and a `modify` must re-validate against that question's logic. Requires the reviewer or admin role: a human judgment, never reachable by an API key. Idempotency-Key honored."
      tags:
        - directives
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateSupersessionRequest"
      responses:
        "201":
          description: The newly created pending supersession.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSupersession"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/directives/{directiveDocumentId}/extractions/{extractionId}:
    get:
      operationId: getDirectiveExtraction
      summary: Get one directive extraction run in full.
      description: "Everything one extraction run produced, for drilling into the directive listing: the run row, its coverage rollup, every coverage-ledger row (the per-block exhaustiveness proof), every durable per-statement flag reason, the supplemental questions it compiled, and the supersessions it produced. 404 unless the run belongs to BOTH the named scenario and the named directive document — both are verified server-side, never trusted from the path. Member-read: any authenticated principal may read this, the same tier as GET.../directives which it drills into."
      tags:
        - directives
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DirectiveDocumentId"
        - $ref: "#/components/parameters/ExtractionId"
      responses:
        "200":
          description: The extraction run and its artifacts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DirectiveExtractionDetail"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /supersessions/{id}/review:
    post:
      operationId: reviewSupersession
      summary: Confirm or reject a supersession.
      description: "A human confirms or rejects a pending supersession — one-shot (pending -> confirmed|rejected); a second review is 409. A rejection requires a rationale (note). Confirmation is REQUIRED before any eligibility-targeted supersession can take effect. Requires the reviewer or admin role: a human judgment, never reachable by an API key. Idempotency-Key honored via IdempotencyMiddleware."
      tags:
        - directives
      parameters:
        - $ref: "#/components/parameters/SupersessionId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReviewSupersessionRequest"
      responses:
        "200":
          description: The reviewed supersession.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSupersession"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/directives/{directiveDocumentId}/diff:
    get:
      operationId: getScenarioDirectiveDiff
      summary: Get a directive document's re-derive diff.
      description: "Diffs the directive document's latest COMPLETED extraction against the run it supersedes: supplemental questions added/removed/changed (matched by the carried-forward lineage_id), supersession candidates appeared/vanished (grouped by the stable GLOBAL target lineage), and confirmed supersessions whose source statement vanished FLAGGED (`flagged_source_vanished` — never silently dropped; the confirmed row stays immutable history, a human resolves the flag). Mirrors the guideline re-upload diff API shape. 409 when the directive document has never completed an extraction. Requires the reviewer or admin role, like the supersession review queue."
      tags:
        - directives
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/DirectiveDocumentId"
      responses:
        "200":
          description: The re-derive diff.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DirectiveDiff"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /directive-statement-flags/{flagId}/dismissal:
    post:
      operationId: dismissDirectiveStatementFlag
      summary: Dismiss a flagged directive statement as not a rule change, or restore it.
      description: |-
        The second exit a flagged unmatched statement needs. manual escape hatch (POST /scenarios/{scenarioId}/supersessions) is the right action when the statement IS a rule change; this is the action when it is not one — the extraction read a sentence as a possible rule change and a human, reading it, concludes there is no rule change to record. Without it such an entry stayed on the review queue forever, so the queue stopped meaning "these need you".

        `action=dismiss` stamps the dismissal (with an optional `rationale`, the dismisser's own words); `action=restore` clears it and puts the statement back on the queue. REVERSIBLE, unlike the one-shot supersession review: a dismissal changes only what a human is asked to look at — it creates no supersession, waives no requirement, and no evaluation reads it — so a hasty click must not hide a real rule change forever.

        The flag BODY is never touched: the reason, the redacted detail, the cited block and the statement's own words remain the immutable record of what the matcher reported at match time.

        Idempotent WITHOUT an Idempotency-Key, by design: repeating the action already recorded is a 200 that writes nothing (and audits nothing), so a client whose response was lost simply re-sends. Asking for the OPPOSITE action is never a conflict — that is what reversible means.

        ONE FLAG ROW IS ONE (statement × cited block), so a statement the document said in three places has three rows: dismiss each. Rows of one statement share `statement_ref` within one extraction run. There is no batch form — refusals are per row (`409 attempt_superseded`) and this operation is idempotent, so re-sending only the rows that refused is free.

        Requires the reviewer or admin role AND a user credential: the dismisser is recorded as a user, so an API key or workflow principal is refused (403), the same rule as a decision sign-off or a reading review.

        ERROR CODES: `403` a non-reviewer role or any API-key credential; `404` no such flag in this tenant; `409` `reason: "attempt_superseded"` — this analysis was superseded by a re-analysis (POST /directive-statement-flags/{flagId}/rematch), and only the newest attempt of a statement may be dismissed, because the review listing shows only the newest and a dismissal you cannot see is not a dismissal; `422` `action` not in the enum or `rationale` longer than 2000 characters.
      tags:
        - directives
      parameters:
        - name: flagId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: The flag row's id, from `flagged_unmatched_reasons[].id` on GET /scenarios/{scenarioId}/directives.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DirectiveStatementFlagDismissalRequest"
      responses:
        "200":
          description: The flag as it now stands, shaped exactly like the listing entry it replaces (including its read-time `source_text`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DirectiveStatementFlag"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /directive-statement-flags/{flagId}/rematch:
    post:
      operationId: rematchDirectiveStatementFlag
      summary: Re-run the automated analysis for one flagged directive statement.
      description: |-
        Two of the six flag reasons do not say anything about the DOCUMENT. `seat_output_invalid` and `seat_transport_error` say the ANALYSIS broke — a review returned unusable output, or a provider was unreachable when the extraction ran — and handing those to a person as "read this yourself" is asking for work a retry would have done. `model_disagreement` is often the same story once a model version moves. This re-runs the match for that ONE statement.

        It reuses the match stage exactly: the same deterministic candidate shortlist over the same published question corpus, the same per-tenant cut size, the same two independent model families, the same prompt, the same agreement rule. A retry judged by different rules would not be a retry.

        TWO OUTCOMES. `outcome: "flagged"` means still unresolved, with the fresh reason recorded as the next `attempt` of the flag rather than written over the old one: the earlier row is the evidence that the first analysis failed the way it did, and the review listing shows only the latest attempt, so a fresh verdict never lingers beside a stale one.

        SYNCHRONOUS, AND IT SPENDS MODEL BUDGET. Two model calls happen inside the request (the same shape as the questions:structure assist endpoint). A request that exceeds the server's 60s bound writes NOTHING — every persistence step happens after both reviews have answered — so a timeout is simply retryable. `Idempotency-Key` is honored and is the right guard for a double-clicked button; a retry whose attempt was claimed by another request between the reviews answering and the write is a clean `409`, never a duplicate row.

        ERROR CODES: `403` a non-reviewer role or any API-key credential (the requester is recorded as a user); `404` no such flag in this tenant; `409` `reason: "dismissal_active"` (dismissed as not-a-rule-change — restore it first), `reason: "attempt_superseded"` (a newer analysis of this statement exists), `reason: "already_resolved"` (a rule change already covers this statement's source) or `reason: "document_erased"`; `422` `reason: "statement_text_unavailable"` — the flag predates the statement text being stored durably, and the analysis judges the extractor's statement rather than raw block text, so re-deriving the document is the honest remedy instead of substituting different input; `503` fewer than two independent model families are configured (a deployment problem, and deliberately NOT recorded as a fresh verdict about the document).
      tags:
        - directives
      parameters:
        - name: flagId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: The flag row's id, from `flagged_unmatched_reasons[].id` on GET /scenarios/{scenarioId}/directives.
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The re-analysis outcome.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DirectiveStatementFlagRematchResult"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/reports:
    post:
      operationId: requestReport
      summary: Request a PDF findings report for a decision.
      description: "Renders an on-demand, immutable, decision-pinned PDF findings report (scenario summary; decision + reason codes + conditions; per-question verdicts with citations; override chain; abstentions/flags in plain language). Starts the ReportGeneration workflow and returns the new report row in `pending`; poll it (or the download endpoint) until it is `ready`. Immutable by construction: re-requesting after a decision recompute produces a NEW report against the new decision — an existing report never changes. Requires reviewer/underwriter access."
      tags:
        - reports
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RequestReportRequest"
      responses:
        "202":
          description: The new report run (poll it for readiness).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Report"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listReports
      summary: List a scenario's findings reports, newest first.
      tags:
        - reports
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of reports.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReportList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/reports/{reportId}:
    get:
      operationId: getReport
      summary: Get one findings report's status.
      tags:
        - reports
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/ReportId"
      responses:
        "200":
          description: The report's current status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Report"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/reports/{reportId}/download:
    get:
      operationId: getReportDownload
      summary: Get a presigned download URL for a ready findings report.
      description: Any authenticated member of the tenant may download (viewer role included).
      tags:
        - reports
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/ReportId"
      responses:
        "200":
          description: A presigned, time-limited download URL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GetReportDownloadResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/audit-timeline:
    get:
      operationId: getScenarioAuditTimeline
      summary: The scenario's reviewer-facing audit timeline, chronological.
      description: "A case-scoped, chronological view over that case's audit events: lifecycle, document uploads, run status transitions, reviewer overrides, decision proposals and reviews, findings-report generation, and export downloads — filtered server-side to the actions a reviewer cares about. Returns the same `AuditEvent`/`AuditEventList` shapes as `GET /v1/audit-events`. Any authenticated member of the tenant may read it."
      tags:
        - audit-timeline
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of the scenario's audit timeline, oldest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditEventList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /scenarios/{scenarioId}/export:
    get:
      operationId: exportScenario
      summary: Download a self-contained export package for one decision.
      description: "Generates and streams a zip archive: the decision + plain-language rationale, every question's verdict with citations and a consensus summary, the reviewer override chain, the scenario's audit timeline, and a document manifest (name, content type, size, sha256, and a presigned download link per document — never the raw bytes, a deliberate size/PII default). Ships both `export.json` (machine-readable) and `summary.md` (human-readable). Built synchronously on each request from already-computed, deterministic read-models — no LLM call, $0. Every export writes an `export.generated` audit_event. Any authenticated tenant member may export (same audience as the decisions/results/report-download reads it assembles)."
      tags:
        - export
      parameters:
        - $ref: "#/components/parameters/ScenarioId"
        - name: decisionId
          in: query
          required: true
          description: Which decision (recomputation) to export — mirrors `RequestReportRequest.decision_id`.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The export archive (`application/zip`).
          content:
            application/zip:
              schema:
                type: string
                format: binary
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /decisions:
    get:
      operationId: listCurrentDecisions
      summary: List every scenario's CURRENT decision, tenant-wide — the review queue.
      description: |-
        Scenarios that have never produced a decision do not appear. This is NOT a recomputation history — the per-scenario history (every seq, newest first) stays at `GET /v1/scenarios/{scenarioId}/decisions`.

        A decision is a PROPOSAL until a recorded human acceptance: an absent `latest_review` means unaccepted. So with `review_state=pending` this endpoint IS the reviewer's tenant-wide review queue — every scenario whose current decision is still waiting on a human sign-off, not just a recent slice of them.

        Rows are ordered by `scenario_id` ascending. Scenario ids are UUIDv7, so that is oldest-scenario-first — queue order — and it is the key the opaque `cursor`/`next_cursor` pagination runs over. Any authenticated tenant member may read, matching the per-scenario decisions list; the reviewer-role gate lives on the review ACTION (`POST.../decisions/{decisionId}/review`), not on reading the queue.
      tags:
        - evaluations
      parameters:
        - $ref: "#/components/parameters/DecisionReviewStateFilter"
        - $ref: "#/components/parameters/DecisionOutcomeFilter"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of current decisions, oldest scenario first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CurrentDecisionList"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /reading-proposals:
    get:
      operationId: listReadingProposals
      summary: List reading proposals tenant-wide — the reviewer's work queue.
      description: |-
        The integrator's enumeration surface, and the entry point to this whole feature for an API client.

        DESIGN STANCE, so you can build against it with confidence: a reading proposal is a durable, addressable object with a globally-unique id, a single unambiguous lifecycle (`open` -> `accepted` | `rejected`, once, forever), and a listing that can be polled or paged with no reference to any UI concept. Everything the in-app reviewer can do, an API client can do with three calls — this listing, `GET /v1/reading-proposals/{proposalId}`, and `POST /v1/reading-proposals/{proposalId}/review`. There is no hidden in-product state and no step that only the web app can take.

        NEWEST FIRST (`id` descending — ids are time-ordered UUIDv7, so this is exact creation order and the keyset cursor is exact rather than approximately-by-timestamp). Defaults to `status=open`, which is the queue; pass `status` explicitly (repeatable) to widen it. Every item carries `scenario_id`, `document_id`, `document_filename`, `page_no` and `block_id`, so a queue row can be rendered and acted on without a second fetch.

        Proposals belonging to an erased document are never returned. Poll this with `created_after` to pick up only what is new, or subscribe to the scenario event stream (GET /v1/scenarios/{scenarioId}/events) for `document.reading_checked` and `reading_proposal.reviewed`.

        Any authenticated tenant member may read this: a tenant-wide review queue is a read, and the role gate lives on the review ACTION. Same posture and justification as GET /v1/decisions.
      tags:
        - reading-checks
      parameters:
        - $ref: "#/components/parameters/ReadingProposalStatusFilter"
        - $ref: "#/components/parameters/ReadingProposalKindFilter"
        - $ref: "#/components/parameters/ReadingProposalScenarioIdFilter"
        - $ref: "#/components/parameters/ReadingProposalDocumentIdFilter"
        - $ref: "#/components/parameters/ReadingProposalCreatedAfterFilter"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of reading proposals, newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadingProposalList"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /reading-proposals/{proposalId}:
    get:
      operationId: getReadingProposal
      summary: Read one reading proposal by its globally-unique id.
      description: |-
        Identical body shape to a list item, so a client that got an id from the tenant-wide queue never has to reconstruct scenario or document ids to read or act on it. This is also the call to make after a `409 already_resolved` from the review endpoint, to see who resolved it and how.

        404 for an id that is not this tenant's, and for a proposal whose document has been erased (its text columns are tombstones, so there is nothing to return).
      tags:
        - reading-checks
      parameters:
        - $ref: "#/components/parameters/ReadingProposalId"
      responses:
        "200":
          description: The proposal.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadingProposal"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /reading-proposals/{proposalId}/review:
    post:
      operationId: reviewReadingProposal
      summary: Accept or reject one reading proposal.
      description: |-
        The one lifecycle transition this object admits: `open` -> `accepted` | `rejected`, once, forever. There is no un-review and no edit; the database enforces that, not this handler.

        `accept` confirms the proposed reading as the EFFECTIVE reading of the block: it appears as `LayoutBlock.effective_text` on every read surface, `LayoutBlock.reading_state` becomes `corrected`, and `LayoutBlock.text` is UNCHANGED — the parser's reading is what every citation, bbox and content hash resolves to, and it stays that way forever. `reject` records that a human looked and is keeping the parser's reading; it is also the honest action behind a `low_confidence` note's "mark as checked", because nothing is being applied. Both actions equally clear the review gate: an answer flagged `evidence_reading_disputed` leaves the needs-review set once every proposal named in that flag is resolved (accepted or rejected).

        ACCEPTING CHANGES NO ANSWER. The answer keeps its verdict, its citations and its history. If the confirmed reading actually changes the answer, that is a re-run — an explicit action, never a silent recomputation.

        IDEMPOTENCY, exactly — three outcomes, and the difference between them matters:
        * open proposal, either action -> `200` with the resolved proposal.
        * already resolved, and you ask for the SAME action -> `200` with the stored proposal and NOTHING written. Safe to retry a request whose response you lost, with or without an `Idempotency-Key`. Note that a repeat carrying a different `note` keeps the note stored first — the row is immutable once resolved.
        * already resolved, and you ask for the OTHER action -> `409` with `reason: "already_resolved"`. You are not told a contradictory decision was applied. Read the recorded resolution with `GET /v1/reading-proposals/{proposalId}`.

        An `Idempotency-Key` is also honored generically: the same key with an identical body replays the stored response; the same key with a different body is `409 urn:ug:error:idempotency_conflict`.

        HUMAN PRINCIPALS ONLY. `resolved_by` is a users(id) reference, and confirming a reading is a human attestation — "I looked at the page image and this is what it says." An API-key credential gets `403` regardless of its scopes. This mirrors POST /v1/scenarios/{scenarioId}/decisions/{decisionId}/review exactly. Requires the reviewer, admin or platform role.

        ERROR CODES: `400` unparseable body; `403` a non-reviewer role or any API-key credential; `404` no such proposal in this tenant; `409` `reason: "already_resolved"` (a contradictory decision) or `reason: "document_erased"` (the document's content was destroyed under, so there is no reading left to confirm); `422` `action` not in the enum or `note` longer than 2000 characters; `503` the store is unavailable.
      tags:
        - reading-checks
      parameters:
        - $ref: "#/components/parameters/ReadingProposalId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReviewReadingProposalRequest"
      responses:
        "200":
          description: The proposal as it now stands — freshly resolved, or already carrying exactly this resolution.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadingProposal"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /reading-proposals:review-batch:
    post:
      operationId: reviewReadingProposalsBatch
      summary: Accept or reject many reading proposals in one call.
      description: |-
        Throughput for the realistic reviewer motion: a 20-page scan can yield thirty proposals, most of them spacing, and "accept the twenty-eight spacing fixes, look at the two that touch numbers" should not be thirty sequential POSTs.

        ALWAYS `200` when the request itself is valid. Each item is its own transaction, applied in REQUEST ORDER, and each gets its own outcome: `resolved` (including the safe replay of a decision already recorded — see the single-item endpoint's idempotency rules), `already_resolved` (a contradictory decision, refused), `not_found`, or `document_erased`. Never all-or-nothing, never a partial-failure 5xx, and never a surprise: `results` is the same length and order as `items`.

        A whole-REQUEST problem is still a normal error, not a per-item outcome: a malformed body is `422`, and an API-key credential or a non-reviewer role is `403` for the entire call — a client must never mistake "you may not do this at all" for "one of those rows was odd".

        At most 200 items. Same role and human-principal rules as the single-item endpoint; `Idempotency-Key` honored.

        The single-item endpoint is the contract; this is throughput over it. Nothing here can do anything the single-item endpoint cannot.
      tags:
        - reading-checks
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReviewReadingProposalsBatchRequest"
      responses:
        "200":
          description: One outcome per requested item, in request order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReviewReadingProposalsBatchResult"
        "400":
          $ref: "#/components/responses/Problem"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /verticals:
    get:
      operationId: listVerticals
      summary: List the kinds of underwriting this platform offers.
      description: |-
        The [global] catalog of verticals, each with the label to show a person. Read it to turn the `vertical` you find on a case, a question set or a tenant into display text, and to populate a picker when you let somebody choose one.

        A vertical is a property of the WORK, not of the customer: a case is one instance of a kind of underwriting, a question set is a rulebook written for one, and a case may only be underwritten against a set whose vertical matches its own. One workspace routinely runs several side by side, so never assume a tenant has exactly one.

        HOW TO JOIN. `key` is the value stored on the work, and it is ABSENT on exactly one entry — the shipped default, which is what a case, set or tenant carrying no `vertical` is on. That is the same absent-means-default spelling every other schema here uses, so the join is a direct comparison in both directions and needs no special case for the blank one. Never render `key` itself: it is a machine value, and the whole reason this endpoint exists is that `equipment_finance` is not a line of business anybody writes on a form.

        The catalog is deployment-wide configuration rather than tenant data, so every entry is visible to every caller and the list is the same for all of them; the order is stable, with the default first. Any authenticated tenant member may read it.
      tags:
        - verticals
      responses:
        "200":
          description: Every vertical this deployment offers, the default first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerticalList"
              examples:
                two_verticals:
                  summary: What this deployment ships today
                  description: "The first entry has no `key`: it is the shipped default, and it is what a case, question set or tenant with no `vertical` is on. Both entries are real — this is the live catalog, not an illustration."
                  value:
                    items:
                      - display_name: Residential Lending
                      - key: contract_review
                        display_name: Contract Review
        "401":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /file-types:
    get:
      operationId: listFileTypes
      summary: List the file-type adapter registry.
      description: Every file type this deployment can read, and for each one whether it is supported today or scheduled. This is what an upload screen reads to show what is accepted now and what is coming. Any authenticated member of the workspace may read it.
      tags:
        - file-types
      responses:
        "200":
          description: The full file-type registry, in key order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FileTypeList"
        "401":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /canonical-keys:
    get:
      operationId: listCanonicalKeys
      summary: List the canonical structured-facts key vocabulary.
      description: |-
        Which scope you ask for depends on what the list is FOR — the same distinction `GET /v1/document-types` draws. Scope it with `question_set_id` whenever the list becomes a PICKER: the values it offers get stored on `evidence_requirements.fact_paths`, and a value from a line of business the set is not bound to is refused at save/publish, so an unscoped picker offers choices that cannot be used. Leave it unscoped only for a key -> `display_name` LOOKUP, where a stored reference must keep resolving whichever vertical it belongs to; the unscoped read returns every vertical's keys together, so each entry carries its own `vertical` to disambiguate.

        Only `question_set_id` is offered, not the case-side `scenario_id` the document-type list also takes: fact_paths are authored into a question SET and nowhere else, so there is no case-scoped fact_paths picker to serve. Any authenticated tenant member may read (standing default: authed, not anonymous).
      tags:
        - canonical-keys
      parameters:
        - name: question_set_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: "Scope the vocabulary to this question set's own line of business (a question set carries the vertical its rulebook is written for). The scope ALWAYS narrows and never falls back: every set carries a binding — an implicit one is the shipped default (`question_sets.vertical` is `NOT NULL DEFAULT ''`, and `''` is a filter, the default vertical's sentinel, not \"everything\"). An unknown or foreign id is a 404. The tenant comes from the caller, not from this id (RLS): a set in another tenant is reported identically to one that does not exist."
      responses:
        "200":
          description: The canonical-key vocabulary in (vertical, key) order — the full mirror when unscoped, or one line of business's keys when scoped.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CanonicalKeyList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /document-types:
    post:
      operationId: createDocumentType
      summary: Create a document-type taxonomy entry.
      description: "Taxonomy identity: `key` is IMMUTABLE from this point on — a later rename is a display_name edit only, never a key change. admin/reviewer (or document-types:write API key) only."
      tags:
        - document-types
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateDocumentTypeRequest"
      responses:
        "201":
          description: The created taxonomy entry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentType"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listDocumentTypes
      summary: List document-type taxonomy entries.
      description: |-
        Open to any authenticated tenant member. The taxonomy is tenant-wide, but a vertical partitions it (a document type belongs to exactly one vertical), so which scope you ask for depends on what the list is FOR.

        Scope it whenever the list becomes a PICKER — the values a picker offers get stored, and a value from the wrong vertical is rejected on save or on publish, so an unscoped picker offers choices that cannot be used. `scenario_id` scopes to a case's bound vertical (the upload wizard's document-type field); `question_set_id` scopes to a question set's bound vertical (the question-authoring evidence picker). The two are mutually exclusive — each names a different vertical binding and there is no defensible answer when they disagree — so passing both is a 422.

        Leave it unscoped only for key -> display-name LOOKUPS, where a stored reference must keep resolving whichever vertical it belongs to. Deprecated entries are returned in every scope (requires existing references to keep resolving) — narrow with `status` to reach the assertable subset.

        Cursor-paginated exactly like the unscoped list (`cursor`/`limit`): scoping narrows the rows, never the paging contract.
      tags:
        - document-types
      parameters:
        - $ref: "#/components/parameters/DocumentTypeStatusFilter"
        - name: scenario_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: Scope the list to the named case's bound vertical (binding, per-case since). Must name a case in the caller's tenant (404 otherwise). Mutually exclusive with `question_set_id` (422).
        - name: question_set_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: "Scope the list to the named question set's bound vertical (the vertical is a property of the work, fixed when the set is created). This is the closed vocabulary a question's `evidence_requirements.doc_types` may name, so it is the list an authoring picker must offer. Every question set carries a binding — a set created without an explicit vertical is bound to the shipped default one — so this parameter always narrows: it never falls back to the tenant's full taxonomy. Must name a question set in the caller's tenant (404 otherwise). Mutually exclusive with `scenario_id` (422)."
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of taxonomy entries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentTypeList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /document-types/{documentTypeId}:
    get:
      operationId: getDocumentType
      summary: Get a document-type taxonomy entry.
      tags:
        - document-types
      parameters:
        - $ref: "#/components/parameters/DocumentTypeId"
      responses:
        "200":
          description: The taxonomy entry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentType"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    patch:
      operationId: updateDocumentType
      summary: Edit a taxonomy entry's mutable fields.
      description: display_name/description/is_directive only — key and status are immutable through this endpoint. admin/reviewer (or document-types:write API key) only.
      tags:
        - document-types
      parameters:
        - $ref: "#/components/parameters/DocumentTypeId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateDocumentTypeRequest"
      responses:
        "200":
          description: The updated taxonomy entry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentType"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /document-types/{documentTypeId}/deprecate:
    post:
      operationId: deprecateDocumentType
      summary: Deprecate a taxonomy entry (the only "delete").
      description: "Forward-only: status moves active -> deprecated and never back. admin/reviewer (or document-types:write API key) only."
      tags:
        - document-types
      parameters:
        - $ref: "#/components/parameters/DocumentTypeId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DeprecateDocumentTypeRequest"
      responses:
        "200":
          description: The deprecated taxonomy entry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocumentType"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-groups:
    post:
      operationId: createQuestionGroup
      summary: Create a question group.
      tags:
        - question-groups
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateQuestionGroupRequest"
      responses:
        "201":
          description: The created question group.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionGroup"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
      description: A question group is the organizational container for a workspace's guidelines, question sets and selection rules.
    get:
      operationId: listQuestionGroups
      summary: List question groups.
      tags:
        - question-groups
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of question groups.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionGroupList"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}:
    get:
      operationId: getQuestionGroup
      summary: Get a question group.
      tags:
        - question-groups
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
      responses:
        "200":
          description: The question group.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionGroup"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}/guidelines:
    post:
      operationId: createGuideline
      summary: Initiate a guideline upload.
      description: Phase one of a two-phase upload. Unlike an ordinary document, a guideline declares its `size_bytes` and `sha256` up front, and both are re-verified server-side at `.../complete`. Re-uploading a revised edition of an existing guideline sets `supersedes_guideline_id`; the original stays immutable.
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateGuidelineRequest"
      responses:
        "201":
          description: Guideline created in `pending` parse status, with a presigned upload URL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateGuidelineResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "413":
          $ref: "#/components/responses/Problem"
        "415":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listGuidelines
      summary: List guidelines in a question group.
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of guidelines.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GuidelineList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}/guidelines/{guidelineId}:
    get:
      operationId: getGuideline
      summary: Get a guideline.
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/GuidelineId"
      responses:
        "200":
          description: The guideline.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Guideline"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    patch:
      operationId: updateGuideline
      summary: Edit a guideline's mutable properties.
      description: "`display_name` only — everything else on a guideline is upload provenance, fixed at creation. PATCH semantics: an omitted field is unchanged; at least one field is required. Every update emits a `guideline_source.updated` audit event. reviewer/admin (or guidelines:write API key) only."
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/GuidelineId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateGuidelineRequest"
      responses:
        "200":
          description: The updated guideline.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Guideline"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}/guidelines/{guidelineId}/blocks:
    get:
      operationId: listGuidelineBlocks
      summary: List a guideline's layout blocks.
      description: Block IDs are the universal citation currency — every criterion/ question citation resolves to a set of these IDs, and this endpoint (plus the batch resolve below) is how the viewer turns a citation into a highlighted page region. Populated once the guideline's parse_status reaches parsed/degraded_parse; empty before that.
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/GuidelineId"
        - $ref: "#/components/parameters/PageNo"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of layout blocks, in reading order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LayoutBlockList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}/guidelines/{guidelineId}/blocks:resolve:
    post:
      operationId: resolveGuidelineBlocks
      summary: Batch-resolve layout block IDs to their full rows.
      description: |-
        The evidence viewer fetches exactly the cited blocks by ID. `items` is unordered here; the scenario-document twin returns reading order.

        A GUIDELINE CITATION IS GUARANTEED RESOLVABLE, so this endpoint has no partial-success case. Parse artifacts are immutable once referenced — a reparse or parser upgrade creates a new guideline source rather than moving blocks out from under existing citations — so an ID that fails to resolve is not a stale reference a client should cope with, it is a corrupted citation graph. It is reported as a `500` integrity fault (`urn:ug:error:internal`) for the WHOLE call, never a partial 200 and never a per-item 404.

        `missing_block_ids` is therefore ALWAYS `[]` on this endpoint. It exists for schema parity with the scenario-document twin (`resolveScenarioDocumentBlocks`), which shares this response schema and where a missing ID IS an ordinary 200 outcome. A client written against either endpoint reads the same shape; only this one can never populate that field.

        ERASURE DOES NOT CHANGE THAT. A content erasure tombstones `layout_blocks.text` IN PLACE — the row, its id, its page number and its bbox all survive — so a citation into an erased guideline still resolves, with `200` and the block's real geometry, and its `text` reads as the tombstone marker rather than the passage. That is the whole point of tombstone-preserving erasure: a citation never dangles, and a viewer is told the content is gone instead of being handed a 404 it would have to guess at. Check the guideline's `erased_at` to render that state deliberately; the marker is a wire-level sentinel and must never be shown to a person as text.

        At most 200 IDs per call, at least 1 — an overflow is `422`.
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/GuidelineId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResolveLayoutBlocksRequest"
            example:
              block_ids:
                - 6f9619ff-8b86-d011-b42d-00c04fc964ff
      responses:
        "200":
          description: The requested block IDs that exist, unordered, plus the IDs that do not.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResolveLayoutBlocksResponse"
              example:
                items:
                  - id: 6f9619ff-8b86-d011-b42d-00c04fc964ff
                    page_no: 12
                    block_idx: 1
                    block_type: text
                    text: An applicant with fewer than two years of documented income history requires additional review.
                    bbox:
                      x0: 72
                      y0: 144
                      x1: 523
                      y1: 180
                      page_w: 612
                      page_h: 792
                    reading_order: 15
                missing_block_ids: []
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}/guidelines/{guidelineId}/pages:
    get:
      operationId: listGuidelinePages
      summary: List a guideline's rendered pages.
      description: Presigned PNG render URLs + pixel dimensions for the evidence viewer to overlay block bboxes on. Populated once the guideline's parse_status reaches parsed/ degraded_parse; empty before that.
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/GuidelineId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of rendered guideline pages, in page order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GuidelinePageList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}/guidelines/{guidelineId}/download:
    get:
      operationId: getGuidelineDownload
      summary: Get a presigned download URL for an uploaded guideline source file.
      description: Any authenticated member of the tenant may download (viewer role included) — same posture as getScenarioDocumentDownload. Only guidelines whose parse_status has moved past `pending` (the upload phase has completed) can be downloaded. 409 with reason `guideline_erased` once the record's content has been erased — the bytes are destroyed and no download URL is minted.
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/GuidelineId"
      responses:
        "200":
          description: A presigned, time-limited download URL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GetGuidelineDownloadResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}/guidelines/{guidelineId}/complete:
    post:
      operationId: completeGuideline
      summary: Finalize a guideline upload.
      description: "Phase two of the upload: verifies the uploaded object server-side (actual size, sha256, and — for content types whose magic bytes are checkable — that they match the declared content type) against the values declared at creation, and transitions `parse_status` from `pending` — or, as a retry, from `parse_failed` — to `uploaded`, `unsupported_pending`, or `parse_failed`. `unsupported_pending` is the committed-format-floor outcome: the content type is accepted but its adapter hasn't landed yet — stored, never rejected, and backfilled once it does. Every other state (`uploaded`/`parsing`/`parsed`/`degraded_parse`/`unsupported_pending`) is a 409. 409 with reason `guideline_erased` once the record's content has been erased — the bytes are destroyed and an erased guideline cannot be re-parsed."
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/GuidelineId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CompleteGuidelineRequest"
      responses:
        "200":
          description: The finalized guideline.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Guideline"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "413":
          $ref: "#/components/responses/Problem"
        "415":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /question-sets:
    post:
      operationId: createQuestionSet
      summary: Create a question set (and its empty draft v1).
      description: "Manual-authoring entry point: creates the question_sets row AND an empty draft version 1 (`source_guideline_ids: []`) in one call, with no guideline document required. Questions are then added via the draft-question endpoints below — there is no separate authoring data path. reviewer/admin (or API key with `question_sets:write`)."
      tags:
        - question-sets
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateQuestionSetRequest"
      responses:
        "201":
          description: The created set and its empty draft v1.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateQuestionSetResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listQuestionSets
      summary: List question sets.
      description: |-
        Every set carries its `vertical` and its `program`, and this list is also where a tenant's programs come from — there is no program catalog, so the set of values these sets carry IS the set of programs the tenant runs.

        THERE IS NO SERVER-SIDE VERTICAL OR PROGRAM FILTER, deliberately: a picker offering only the sets a given case may be underwritten against filters CLIENT-SIDE on those two fields (match the case's `vertical` AND its `program`, absent matching absent). The server's job is to REFUSE a mismatched pairing at the three binding doors, which it does regardless of what any picker offered; a filter here would be a convenience, not a control, and a client that trusted it instead of handling the 422 would still be wrong.
      tags:
        - question-sets
      parameters:
        - name: question_group_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filter to sets in this question group.
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of question sets.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetList"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}:
    get:
      operationId: getQuestionSet
      summary: Get a question set.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
      responses:
        "200":
          description: The question set.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSet"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions:
    post:
      operationId: createQuestionSetVersion
      summary: Create a new draft version (copy-on-write).
      description: "Copies every question of the base version (default: the set's current published version; `from_version` overrides) into a new draft — new ids, same lineage_id and content. Fails with 409 if the set already has a live draft/in_review version."
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateQuestionSetVersionRequest"
      responses:
        "201":
          description: The new draft version.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetVersion"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listQuestionSetVersions
      summary: List a set's versions.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of versions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetVersionList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}:
    get:
      operationId: getQuestionSetVersion
      summary: Get a version (by version number or version id).
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
      responses:
        "200":
          description: The version.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetVersion"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/submit:
    post:
      operationId: submitQuestionSetVersion
      summary: Submit a draft for review (draft -> in_review).
      description: Optional workflow station; publishing straight from draft is also allowed.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
      responses:
        "200":
          description: The version, now in_review.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetVersion"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/reopen:
    post:
      operationId: reopenQuestionSetVersion
      summary: Reopen an in_review version for further editing (in_review -> draft).
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
      responses:
        "200":
          description: The version, now draft.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetVersion"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/publish:
    post:
      operationId: publishQuestionSetVersion
      summary: Start publishing a draft or in_review version (async).
      description: |-
        Runs the synchronous publish gate (every question must pass static validation, generated citations must resolve, no generation run may be in flight) and then hands off to the durable publish workflow, returning `202` with the version now in `publishing`. The workflow runs the text_md/logic consistency review — real model-minutes on a large set — and then commits: freeze the version, compute change_summary + a question_set_diffs row against the previously published version (if any), flip that version to superseded, update the set's current_version_id.

        Poll `GET.../publish` for progress; the version reaches `published` or `publish_failed` (with a typed `publish_error`). A repeated POST while a publish is already in flight is a no-op that returns the same `202` — the workflow ID is deterministic. A `publish_failed` version may be POSTed again to retry, or reopened to `draft` to edit first.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublishQuestionSetVersionRequest"
      responses:
        "202":
          description: The version, now publishing (poll it for progress).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetVersion"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
    get:
      operationId: getQuestionSetVersionPublishStatus
      summary: Poll a version's publish progress.
      description: "The progress-polling resource for the async publish. Safe to call in any status: a version that was never published simply reports its own status with no stage and zero progress. `stage` is derived (`consistency` while the review is running, `committing` once its verdicts are checkpointed) and is absent once terminal."
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
      responses:
        "200":
          description: The publish status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetPublishStatus"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/diff:
    get:
      operationId: diffQuestionSetVersions
      summary: Diff two versions of a set, per-lineage.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
        - name: from
          in: query
          required: true
          schema:
            type: string
          description: The version to diff from (version number or version id).
      responses:
        "200":
          description: The diff.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetDiff"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/questions:
    get:
      operationId: listQuestions
      summary: List a version's questions.
      description: Read side also serves published versions.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of questions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    post:
      operationId: createQuestion
      summary: Add a question to a draft.
      description: Drafts only (409 otherwise). provenance is forced to human_authored — this is the manual-authoring data path; generated questions are written only by the generation pipeline.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/QuestionCreate"
      responses:
        "201":
          description: The created question.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Question"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/questions:all:
    get:
      operationId: listAllQuestions
      summary: Every question of a version, unpaginated.
      description: |-
        The QuestionTable review surface needs the WHOLE version to render (paginated listQuestions answers nothing until every page lands — see that operation's description) — this is the same unbounded read internal callers already get from the store's ListAllQuestionsByVersion (copy-on-write draft creation, publish validation, diff computation), exposed directly instead of making the client drain up to DEFAULT_MAX_PAGES requests to reconstruct it (a published 4,242-question version cost 22 sequential round trips before this endpoint existed).

        NOT REACHABLE BY AN EMBED PRINCIPAL — this path carries no `{scenarioId}`, and the read-only embed session is admitted only on scenario-scoped GETs. When what you want is the questions ONE RUN pinned, the scenario-nested `GET /scenarios/{scenarioId}/evaluations/{evaluationId}/questions` returns the same `AllQuestionsResponse` and resolves the version server-side from the run, so there is no (set, version) pair to name.

        Ordered (ordinal, id) — the version's DISPLAY order, not listQuestions's `id`-keyset order.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
      responses:
        "200":
          description: Every question of the version, in display order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AllQuestionsResponse"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/questions/{questionId}:
    get:
      operationId: getQuestion
      summary: Get a question.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
        - $ref: "#/components/parameters/QuestionId"
      responses:
        "200":
          description: The question.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Question"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    patch:
      operationId: updateQuestion
      summary: Edit a draft question in place.
      description: Drafts only (409 otherwise). The first human content edit of a generated question flips its provenance to human_edited (one-way).
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
        - $ref: "#/components/parameters/QuestionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/QuestionPatch"
      responses:
        "200":
          description: The updated question.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Question"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    delete:
      operationId: deleteQuestion
      summary: Remove a question from a draft.
      description: Drafts only (409 otherwise). History remains in earlier versions and audit_events.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
        - $ref: "#/components/parameters/QuestionId"
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/questions:structure:
    post:
      operationId: structureQuestion
      summary: Turn plain text into a structured draft question, with model assistance.
      description: "Plain text in, a short queued task out: poll it (GET.../structure-tasks/{taskId}) for its proposed structured question — logic AST, evidence requirements, criticality, plus ambiguities/notes for the human to confirm or edit (the proposal carries no `answer_schema` — that convenience column is always server-derived from `logic`). The proposal is PERSISTED as soon as it's produced (unlike the original, which was wholly stateless): navigating away mid-assist loses nothing. The human still confirms via the ordinary createQuestion call (provenance is still forced to human_authored there, and the proposal carries no citations). The task fails (never a malformed proposal) when the structuring assist is not configured, or when the model's output stayed invalid after one repair reprompt."
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StructureQuestionRequest"
      responses:
        "202":
          description: The queued task (poll it for the proposal).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StructureTask"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/structure-tasks:
    get:
      operationId: listStructureTasks
      summary: List a version's authoring-structuring assist tasks.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of tasks.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StructureTaskList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/versions/{version}/structure-tasks/{taskId}:
    get:
      operationId: getStructureTask
      summary: Read one structuring task, including its progress.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Version"
        - $ref: "#/components/parameters/StructureTaskId"
      responses:
        "200":
          description: The task, with its proposal once completed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StructureTask"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/generate:
    post:
      operationId: startGenerationRun
      summary: Start a question-generation run for a set.
      description: Triggers the QuestionGeneration workflow against the set's live draft (created implicitly from the current published version when none exists). 409 when a run is already active for the set or when any named guideline is not parsed/degraded_parse. The one-active-run rule makes a duplicate submission converge on the same run rather than duplicating work.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/GenerateRequest"
      responses:
        "202":
          description: The generation run (poll it for progress).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationRun"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/generation-runs:
    get:
      operationId: listGenerationRuns
      summary: List a set's generation runs.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of runs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationRunList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/generation-runs/{generationRunId}:
    get:
      operationId: getGenerationRun
      summary: Get one generation run (the progress-polling resource).
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/GenerationRunId"
      responses:
        "200":
          description: The run with its stats.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationRun"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/generation-runs/{generationRunId}/resume:
    post:
      operationId: resumeGenerationRun
      summary: Resume a paused generation run.
      description: |-
        Restarts a run that stopped on an AI-service outage (`stats.vendor_paused`) or on its configured spend ceiling (`stats.budget_paused`). The run's stage checkpoints were deliberately preserved by the pause, so resuming re-enters at the stage that stopped and repeats no paid work — the same zero-duplicate-spend promise `POST /v1/evaluations/{evaluationId}/resume` makes, and the reason a pause is never routed through the terminal failure path.

        This is the customer-reachable half of a resume that previously existed only as an operator command, which is what the automatic-retry escalation notice now points at. The automatic sweeper calls the same code, so a run that resumes itself and a run resumed here are the same transition.

        409 (with a machine-readable `reason`) when the run is terminal or carries no pause marker (`not_resumable`), or when a budget-paused run's spend still meets the ceiling that stopped it, so resuming would immediately re-pause (`budget_exhausted`). Resuming a vendor-paused run while the outage continues is deliberately ALLOWED: re-entry costs nothing and simply re-pauses, so it is safe to let someone try.

        Audited (`generation_run.resumed`, carrying the pause class and the acting principal). A run that belongs to a guideline re-upload diff restarts that diff's pipeline rather than a bare generation run, so the comparison it exists to produce still happens.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/GenerationRunId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: The run, moving again (poll it as usual).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationRun"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/generation-runs/{generationRunId}/cancel:
    post:
      operationId: cancelGenerationRun
      summary: Cancel an in-flight generation run.
      description: |-
        A cancel is a STOP, not a rollback. The run KEEPS whatever it had already spent (`stats.cost_usd` is preserved, never re-zeroed) and KEEPS its completed stage checkpoints and any questions already drafted into the draft version; it simply stops advancing. The set's one-active-run slot is freed, so a fresh run may start.

        Idempotent: canceling an already-`canceled` run is a no-op `202` that returns the same run. Canceling a run that already reached `completed` or `failed` is a `409` (that work is done — there is nothing to stop).

        A generation run that is the underlying half of an ACTIVE re-upload diff is a `409` with `reason: diff_owned` — it is driven by the diff run's pipeline and must be canceled from there, not here (nothing is written on this rejection). A run whose diff already finished is standalone and cancelable.

        Audited (`generation_run.canceled`, carrying the acting principal and the preserved cost). Generation runs have no SSE stream of their own; the run resource's terminal state on the next poll is the boundary signal.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/GenerationRunId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: The run, now `canceled` (or already terminal-canceled — idempotent).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationRun"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/diff-runs:
    post:
      operationId: startDiffRun
      summary: Start a guideline re-upload anchor-match diff run.
      description: Runs the extraction and coverage half of the generation pipeline — never the drafting half — against a re-uploaded guideline, matches its criteria against the question set's currently published version (exact anchor first, then lexical similarity, then semantic equality), and projects the matches onto questions, leaving human-owned questions alone. `409` when a diff run is already active for the set, when the set has no published version to diff against, or when the named guideline has not parsed.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StartDiffRunRequest"
      responses:
        "202":
          description: The diff run (poll it for progress).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiffRun"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "429":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listDiffRuns
      summary: List a set's diff runs.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of diff runs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiffRunList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/diff-runs/{diffRunId}:
    get:
      operationId: getDiffRun
      summary: Get one diff run (the progress-polling resource).
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/DiffRunId"
      responses:
        "200":
          description: The run with its stats.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiffRun"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/diff-runs/{diffRunId}/items:
    get:
      operationId: listDiffRunItems
      summary: List a diff run's per-lineage proposals.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/DiffRunId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of diff items.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiffItemList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/diff-runs/{diffRunId}/items/{itemId}:
    post:
      operationId: dispositionDiffItem
      summary: Record a reviewer's disposition of one proposal.
      description: accept/reject/modify. A `source_changed_review_required` item starts `flagged` (a human_edited question whose source criteria changed — never auto-modified) and must be explicitly dispositioned here before the run can apply. `modify` requires `reviewer_content` (the edited proposed question body).
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/DiffRunId"
        - $ref: "#/components/parameters/DiffItemId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DispositionDiffItemRequest"
      responses:
        "200":
          description: The dispositioned item.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiffItem"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/diff-runs/{diffRunId}/apply:
    post:
      operationId: applyDiffRun
      summary: Apply a reviewed diff run into a new draft version.
      description: "Copy-on-write: unchanged questions are re-anchored onto the new criteria, accepted/modified adds and updates are materialized, accepted/modified deletes are dropped, rejected proposals leave their question unchanged. Every item must be dispositioned first (409 otherwise — a flagged or still-proposed item blocks apply). Does NOT publish — publish the returned draft through the existing gate separately."
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/DiffRunId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The new draft version.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/QuestionSetVersion"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-sets/{questionSetId}/diff-runs/{diffRunId}/discard:
    post:
      operationId: discardDiffRun
      summary: Abandon a diff run without applying anything.
      tags:
        - question-sets
      parameters:
        - $ref: "#/components/parameters/QuestionSetId"
        - $ref: "#/components/parameters/DiffRunId"
      responses:
        "200":
          description: The discarded run.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DiffRun"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}/guidelines/{guidelineId}/criteria:
    get:
      operationId: listCriteria
      summary: List criteria extracted from a guideline.
      description: The extraction product — machine intermediate + audit artifact backing the provenance views. Optionally filtered to one generation run.
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/GuidelineId"
        - name: generation_run_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of criteria.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CriterionList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /question-groups/{questionGroupId}/guidelines/{guidelineId}/coverage:
    get:
      operationId: getCoverage
      summary: Get the coverage-ledger partition proof for a guideline + run.
      description: 'Aggregates guideline_coverage_ledger: "N blocks examined; K criterion-source; R non-normative; 0 unaccounted".'
      tags:
        - guidelines
      parameters:
        - $ref: "#/components/parameters/QuestionGroupId"
        - $ref: "#/components/parameters/GuidelineId"
        - name: generation_run_id
          in: query
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The partition proof.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CoverageReport"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /audit/export:
    get:
      operationId: getAuditExport
      summary: Download the tenant's whole audit chain for independent, offline re-verification.
      description: |-
        It is built so a customer, auditor or regulator can recompute and confirm the chain OFFLINE, with no access to this system and no trust in it, using only the parameters the document carries.

        THE POINT IS TAIL-TRUNCATION DETECTION. The hash chain already makes any edit or deletion in the middle self-evident. What it cannot see on its own is the newest rows being deleted — a truncated chain still verifies. The `checkpoints` and `signed_head` in this export are Ed25519-signed attestations of the chain head (event count + head hash). A customer who keeps one OUT OF BAND detects truncation later: a subsequent export whose head has moved backward — fewer events, or a head hash that no longer reproduces at the retained count — is proof the tail was cut, and the operator cannot forge a copy the customer already holds. See of the spec for the step-by-step verification procedure.

        A pure read — it writes no audit_event of its own. Admin-role only, and never reachable with an API key: the whole tenant-wide audit trail, including every mutation's before/after, is an accountable human matter (the same posture as `GET /v1/idp-connection`). Recording a checkpoint is the separate `POST /v1/audit/checkpoints`.
      tags:
        - audit-events
      responses:
        "200":
          description: The self-describing, offline-verifiable audit export.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditExport"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /audit/checkpoints:
    post:
      operationId: recordAuditCheckpoint
      summary: Record a signed checkpoint of the audit chain head — the tail-truncation anchor.
      description: |-
        Captures this tenant's current audit-chain head (total event count + head hash), signs it with the deployment's Ed25519 key, and records the signed checkpoint as one audit_event (`audit_checkpoint.recorded`) — so the anchor is itself append-only, hash-linked and never deleted, exactly like every other audit event.

        The response is the signed checkpoint. KEEP IT OUT OF BAND — email it to yourself, file it, commit it to a repo you control. Its value as a trust anchor comes entirely from you holding a copy the operator of this system cannot silently rewrite. Record one on a schedule (say daily); each one pins a lower bound on how long your chain was, and the export lists them all.

        A checkpoint attests the head AS OF the read (`event_count = N`); the act of recording it becomes event N+1. That is deliberate — a checkpoint is a lower bound ("at least N events existed, ending at this hash"), which is exactly what truncation detection needs. Admin-role only, never reachable with an API key (same posture as the export). `503` when this deployment has no signing key configured.
      tags:
        - audit-events
      responses:
        "201":
          description: The recorded, signed checkpoint. Retain it out of band.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditCheckpoint"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /audit-events:
    get:
      operationId: listAuditEvents
      summary: Tenant-scoped audit-event read.
      description: "Any authenticated tenant member may read (this is a read path — it never emits an audit_event of its own). Filters follow: resource_type/resource_id (both optional, independently). Set include_questions=true with resource_type=question_set_version and a resource_id to get one version's complete editing narrative in a single query: the version's own lifecycle events (created, submitted, reopened, published, superseded — resource_type=question_set_version, resource_id=<version id>) merged with every question.created/ question.updated/question.deleted event whose before/after snapshot names that version id (attributes question mutations to resource_type=question, resource_id=<question id> — NOT to the owning version — so a plain resource_type/resource_id filter cannot recover a version's question-edit history by itself; include_questions bridges that gap). 422 when include_questions=true is combined with any other resource_type, or with no resource_id."
      tags:
        - audit-events
      parameters:
        - name: resource_type
          in: query
          required: false
          schema:
            type: string
          description: e.g. "question_set_version", "question", "question_set", "generation_run".
        - name: resource_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
        - name: include_questions
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: Requires resource_type=question_set_version and resource_id (see above).
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of audit events, oldest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditEventList"
        "401":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /api-keys:
    post:
      operationId: createApiKey
      summary: Mint an API key for this tenant.
      description: |-
        Returns the key's full value EXACTLY ONCE, in this response. Only its sha256 hash and its 12-character lookup prefix are stored, so a lost key is replaced, never recovered.

        Restricted to user principals holding `platform` or `admin`: an API key that could mint API keys would be a privilege-escalation path (a leaked narrow-scope key could widen itself), so key principals are refused regardless of scope.

        Deliberately NOT idempotent (no `Idempotency-Key` parameter, unlike every other POST here): replaying a stored response would mean persisting a usable plaintext credential in `idempotency_keys`, defeating the hash-only design. Retry semantics are the caller's: a create whose response was lost leaves an unused key, visible via GET and revocable.
      tags:
        - api-keys
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateApiKeyRequest"
      responses:
        "201":
          description: The created key, including its plaintext value (shown only here).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyCreated"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listApiKeys
      summary: List this tenant's API keys (metadata only — never the key value).
      description: 'Revoked and expired keys are included, with `revoked_at` / `expires_at` set: the audit question "what could reach this tenant, and when did that stop" needs them.'
      tags:
        - api-keys
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of API-key metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyList"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /api-keys/{apiKeyId}:
    delete:
      operationId: revokeApiKey
      summary: Revoke an API key (soft; audited; effective immediately).
      description: Sets `revoked_at`. Already-revoked is 409, not a silent success — the caller asked to change something that had already changed.
      tags:
        - api-keys
      parameters:
        - $ref: "#/components/parameters/ApiKeyId"
      responses:
        "200":
          description: The revoked key's metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKey"
        "401":
          $ref: "#/components/responses/Problem"
        "403":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /task-notification-subscriptions:
    post:
      operationId: createTaskNotificationSubscription
      summary: Subscribe to a task already in flight.
      description: The standalone path — as opposed to the inline `notifications[]` array carried on a task-creating request (POST.../generate.../diff-runs.../questions:structure.../documents/{id}/complete) — for adding a subscription to a task after it has already started. task_kind must be one of the recognized values; an unknown one is 422.
      tags:
        - notifications
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateTaskNotificationSubscriptionRequest"
      responses:
        "201":
          description: The created subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskNotificationSubscription"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "422":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    get:
      operationId: listTaskNotificationSubscriptions
      summary: List the tenant's task-notification subscriptions.
      tags:
        - notifications
      parameters:
        - $ref: "#/components/parameters/TaskKindFilter"
        - $ref: "#/components/parameters/TaskIdFilter"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of subscriptions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskNotificationSubscriptionList"
        "401":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /task-notification-subscriptions/{subscriptionId}:
    get:
      operationId: getTaskNotificationSubscription
      summary: Get one task-notification subscription.
      tags:
        - notifications
      parameters:
        - $ref: "#/components/parameters/TaskNotificationSubscriptionId"
      responses:
        "200":
          description: The subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskNotificationSubscription"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
    delete:
      operationId: cancelTaskNotificationSubscription
      summary: Cancel a subscription (soft; audited).
      tags:
        - notifications
      parameters:
        - $ref: "#/components/parameters/TaskNotificationSubscriptionId"
      responses:
        "200":
          description: The canceled subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskNotificationSubscription"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /task-notification-subscriptions/{subscriptionId}/deliveries:
    get:
      operationId: listNotificationDeliveries
      summary: List a subscription's delivery attempts (debugging; dead-letter visibility).
      description: One row per delivery attempt- SET, not per HTTP/SMTP attempt — `attempts` counts sub-attempts.
      tags:
        - notifications
      parameters:
        - $ref: "#/components/parameters/TaskNotificationSubscriptionId"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: One page of delivery attempts, oldest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationDeliveryList"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /task-notification-subscriptions/{subscriptionId}/deliveries/{deliveryId}:
    get:
      operationId: getNotificationDelivery
      summary: Get one delivery attempt.
      tags:
        - notifications
      parameters:
        - $ref: "#/components/parameters/TaskNotificationSubscriptionId"
        - $ref: "#/components/parameters/NotificationDeliveryId"
      responses:
        "200":
          description: The delivery.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationDelivery"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /task-notification-subscriptions/{subscriptionId}/deliveries/{deliveryId}/redeliver:
    post:
      operationId: redeliverNotificationDelivery
      summary: Manually retry a dead-lettered delivery.
      description: "A redeliver is a NEW send cycle: the delivery_id and the stored payload are unchanged and `attempts` keeps accumulating, but the signing-secret pin is cleared, so a webhook redelivery is signed with whichever secret is active NOW rather than the one its first cycle pinned. That is what keeps a long-dead delivery recoverable after the original secret's usable value has been destroyed."
      tags:
        - notifications
      parameters:
        - $ref: "#/components/parameters/TaskNotificationSubscriptionId"
        - $ref: "#/components/parameters/NotificationDeliveryId"
      responses:
        "200":
          description: The delivery, reset to pending and re-queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationDelivery"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "409":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
  /webhook-signing-secret:
    get:
      operationId: getTenantDefaultWebhookSecret
      summary: Get the tenant-default webhook signing secret's metadata.
      description: Metadata only (id, secret_last4, created_at, rotated_at) — never the raw secret value, which is shown only once, at creation/ rotation time.
      tags:
        - webhook-signing-secret
      responses:
        "200":
          description: The tenant-default secret's metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSigningSecretMetadata"
        "401":
          $ref: "#/components/responses/Problem"
        "404":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
  /webhook-signing-secret/rotate:
    post:
      operationId: rotateTenantDefaultWebhookSecret
      summary: Rotate the tenant-default webhook signing secret.
      description: The scope every ad hoc webhook URL always signs against (registered/reusable webhook endpoints were removed; this is the ONLY webhook signing secret a tenant has). Returns the new secret's full value exactly once.
      tags:
        - webhook-signing-secret
      responses:
        "200":
          description: The new secret, shown exactly once.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSecretRotated"
        "401":
          $ref: "#/components/responses/Problem"
        "500":
          $ref: "#/components/responses/Problem"
        "503":
          $ref: "#/components/responses/Problem"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |-
        Authorization: Bearer <credential>. The credential is either a user JWT from the sign-in service this deployment is configured against, or an API key (ug_live_… / ug_test_…). Authorization is deny-by-default. User principals carry a role (platform ⊇ admin ⊇ reviewer ⊇ viewer); API-key principals carry scopes that mirror resource groups (scenarios:write, question_sets:write, guidelines:write, document-types:write, notifications:write). Reads are open to any authenticated member; resource writes need the reviewer role or the matching:write scope; the decision-review / override surface needs the reviewer role (human-only — not reachable by an API key); the tenant-admin surface (notification deliveries, tenant settings, API keys, identity-provider configuration, invitations) needs the admin role; the workspace roster (`GET /v1/users`) is readable by any member but, like every other membership operation, is human-only and refused to API keys at every scope; the INTERNAL-ONLY platform surface (LLM spend/cost, cache metrics, tenant evaluation policy) needs the platform role — never satisfied by a tenant's own admin.

        THE TOKEN CONTRACT (user JWTs). Obtain an access token from the sign-in service this deployment is configured against — the OAuth 2.0 authorization-code + PKCE flow for a human, client credentials for a machine user — and send it verbatim. Send the ACCESS token, not the ID token: the API reads only the claims below and refuses tokens carrying ID-token markers (`at_hash`, `nonce`). The API reads exactly three things from the access token, and nothing else it carries reaches any decision: `sub`, the identity key that must resolve to a known user; `iss`, which must be this deployment's configured issuer; and, when the sign-in request carried the required scope below, the organization the subject belongs to, which is cross-checked against the organization the caller's tenant is bound to. Unknown claims are ignored — extra claims never break a request and never grant anything. `email` is not read from the token at all (it is not in one), and no claim selects a tenant or sets a role: the tenant and the role come from `sub` alone.

        THE SCOPE YOU MUST SEND. Send `urn:zitadel:iam:user:resourceowner` in your scope list, always. It is a CONSTANT — that literal string, the same for every caller of every deployment. It is NOT a template and there is no identifier to substitute into it: it asks the sign-in service to state which organization the subject already belongs to, which is why nothing needs to be issued to you and there is nothing to look up. Requesting it is required once your tenant is bound to an organization, and harmless before that, so send it from your first request rather than waiting to be told your tenant has been bound. A token without it is refused once the binding exists, and the refusal is the same opaque 401 as every other authentication failure — so if previously-working tokens start failing and nothing else changed, this scope is the first thing to check.

        A DIFFERENT SCOPE THAT IS EASY TO CONFUSE WITH IT: a scope of the shape `urn:zitadel:iam:org:id:{organization_id}` pins the SIGN-IN SCREEN to one organization, for branding. It is optional, it is a matter for whoever configures your login page, and it is NOT the scope this API checks. Sending it instead of the constant is not a substitute. It happens to mint the same claim while the subject belongs to the organization you pinned, and mints nothing at all when they do not — so a client relying on it works until it silently stops. Send the constant.

        WHEN IT IS WRONG. Every failure of the above is a single 401 `urn:ug:error:unauthorized` with the same body: an expired or badly-signed token, a token from another issuer, a missing or unreadable `sub`, a subject we do not know, and an organization that contradicts the tenant's are deliberately INDISTINGUISHABLE in the response, so no caller can use the API to probe which one it was. Every such 401 carries a `WWW-Authenticate: Bearer` response header (RFC 6750 / RFC 7235), naming the scheme to present — and deliberately nothing more: no `error`, no `realm`, no `error_description`, because those would reintroduce exactly the probe the single opaque body exists to deny. The `request_id` in the problem body is what support correlates against the server-side log that does say. 403 means the opposite — the credential authenticated fine, and the principal is not allowed: an insufficient role or scope, a deactivated user, or a suspended tenant (`urn:ug:error:tenant_suspended`).

        ONE 403 IS NOT AN AUTHORIZATION FAILURE AT ALL: `urn:ug:error:not_admitted`. It means the token authenticated a real person in an organization bound to a workspace here, and nobody has granted that person access to it — there is no pending invitation for their verified address, or the one there was has lapsed or been withdrawn. It is a 403 rather than a 401 on purpose, because the credential is valid and re-authenticating is the one action guaranteed not to help: a client that treats it as an expired session will loop. Show the `detail` and stop. Every reason carries this same code and the same `detail` — there is no `reason` member here, deliberately, because a body that said WHICH door was shut would be a way to probe a workspace's admission state. The fix is a human one: an administrator of that workspace invites the person (`POST /v1/invitations`), after which the SAME credential works, with no further sign-in needed. Note that the person is not admitted merely by belonging to the organization, and never by holding an address at a domain the workspace has claimed.
  parameters:
    ScenarioId:
      name: scenarioId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ScenarioStatusFilter:
      name: status
      in: query
      required: false
      description: Filter scenarios to one status.
      schema:
        $ref: "#/components/schemas/ScenarioStatus"
    ScenarioDerivedFromFilter:
      name: derived_from_scenario_id
      in: query
      required: false
      description: "Narrow the list to the cases started from this one — the forward half of the lineage `Scenario.derived_from_scenario_id` records. Matches the IMMEDIATE source only: a chain does not flatten, so a grandchild is not listed here, it is listed under its own parent. An id that names no case in the caller's tenant is not an error — it simply matches nothing. A filter like any other: it composes with `status`, `q` and `sort`, pages over the same opaque cursor, and changing it invalidates an existing cursor."
      schema:
        type: string
        format: uuid
    DocumentId:
      name: documentId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    BreakGlassRequestId:
      name: requestId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    BreakGlassStatusFilter:
      name: status
      in: query
      required: false
      description: Narrow the page to one lifecycle state — `requested` is the approval queue. Absent means every state, newest first.
      schema:
        type: string
        enum:
          - requested
          - approved
          - denied
          - revoked
    QuestionGroupId:
      name: questionGroupId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DocumentTypeId:
      name: documentTypeId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DocumentTypeStatusFilter:
      name: status
      in: query
      required: false
      description: Filter taxonomy entries to one status.
      schema:
        $ref: "#/components/schemas/DocumentTypeStatus"
    QuestionSetId:
      name: questionSetId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    GenerationRunId:
      name: generationRunId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DiffRunId:
      name: diffRunId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    EvaluationId:
      name: evaluationId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    EvaluationIdFilter:
      name: evaluation_id
      in: query
      required: false
      description: Narrow the cost rollup to one evaluation run.
      schema:
        type: string
        format: uuid
    ReportId:
      name: reportId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DecisionId:
      name: decisionId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    AnswerId:
      name: answerId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    SupersessionId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    SupersessionStatusFilter:
      name: status
      in: query
      required: false
      description: Filter supersessions to one review status.
      schema:
        $ref: "#/components/schemas/SupersessionReviewStatus"
    DecisionReviewStateFilter:
      name: review_state
      in: query
      required: false
      description: Filter GET /v1/decisions to one human-acceptance state of the scenario's CURRENT decision. `pending` = no `decision_reviews` row exists for it yet (an unaccepted proposal — this is the review queue); `accepted`/`rejected` match the newest review's action. Omitted = no filter.
      schema:
        $ref: "#/components/schemas/DecisionReviewState"
    DecisionOutcomeFilter:
      name: outcome
      in: query
      required: false
      description: Filter GET /v1/decisions to one current-decision outcome.
      schema:
        $ref: "#/components/schemas/DecisionOutcome"
    StructureTaskId:
      name: taskId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DiffItemId:
      name: itemId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    GuidelineId:
      name: guidelineId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    Version:
      name: version
      in: path
      required: true
      description: A question_set_versions row, addressed either by its per-set version number (e.g. "2") or its version id (UUID).
      schema:
        type: string
    QuestionId:
      name: questionId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    TaskNotificationSubscriptionId:
      name: subscriptionId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    UserId:
      name: userId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    TenantId:
      name: tenantId
      in: path
      required: true
      description: The id of the tenant to act on. This is a PLATFORM-scoped path — the operator names the target tenant explicitly, which is why it is never the caller's own tenant (that is `/v1/tenants/me`).
      schema:
        type: string
        format: uuid
    ContractId:
      name: contractId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    InvitationId:
      name: invitationId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ApiKeyId:
      name: apiKeyId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    NotificationDeliveryId:
      name: deliveryId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    TaskKindFilter:
      name: task_kind
      in: query
      required: false
      description: Filter subscriptions to one task_kind.
      schema:
        type: string
    TaskIdFilter:
      name: task_id
      in: query
      required: false
      schema:
        type: string
        format: uuid
    SearchQuery:
      name: q
      in: query
      required: false
      description: "Case-insensitive substring match over the resource's caller-facing reference. Absent or blank means \"no search\". Matching is literal — `%` and `_` are not wildcards. A filter like any other: changing it invalidates a cursor."
      schema:
        type: string
        maxLength: 200
    SortOrder:
      name: sort
      in: query
      required: false
      description: 'Creation-order direction — `newest` (default, `id` DESC) or `oldest` (`id` ASC). ids are UUIDv7, so id order IS `created_at` order and both directions are keyset-paginated by the same opaque cursor ("base64 of a UUIDv7 keyset position"). This parameter deliberately admits no other sort key: any non-id ordering would need a composite cursor and a different pagination contract.'
      schema:
        type: string
        enum:
          - newest
          - oldest
        default: newest
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque pagination cursor from a previous page's `next_cursor`.
      schema:
        type: string
    Limit:
      name: limit
      in: query
      required: false
      description: Page size (default 50, max 200).
      schema:
        type: integer
        default: 50
        minimum: 1
        maximum: 200
    PageNo:
      name: page_no
      in: query
      required: false
      description: Filter layout blocks to one page.
      schema:
        type: integer
        minimum: 1
        maximum: 1000000
    LogicalDocumentId:
      name: logicalDocumentId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DirectiveDocumentId:
      name: directiveDocumentId
      in: path
      required: true
      description: A directive logical document's id (logical_documents.id).
      schema:
        type: string
        format: uuid
    ExtractionId:
      name: extractionId
      in: path
      required: true
      description: One directive extraction run's id (directive_extractions.id).
      schema:
        type: string
        format: uuid
    CanonicalKeyFilter:
      name: key
      in: query
      required: false
      description: Filter facts to one canonical key.
      schema:
        type: string
    EntityRefFilter:
      name: entity_ref
      in: query
      required: false
      description: Filter facts to one entity_ref.
      schema:
        type: string
    ReadingProposalId:
      name: proposalId
      in: path
      required: true
      description: A reading proposal's globally-unique id.
      schema:
        type: string
        format: uuid
    ReadingCheckAttempt:
      name: attempt
      in: query
      required: false
      description: "Select one reading-check attempt by its 1-based number instead of the latest. Attempts are APPENDED (a re-run never replaces one), so attempt history is provenance and stays readable forever. On the check endpoint, an attempt number that does not exist returns `status: \"not_applicable\"`; on the proposals listing it returns an empty page — neither is a 404, for the reason the check endpoint's description gives."
      schema:
        type: integer
        minimum: 1
    ReadingProposalStatusFilter:
      name: status
      in: query
      required: false
      description: Filter to one or more proposal statuses (`open` | `accepted` | `rejected`). REPEATABLE — `?status=open&status=accepted` — because the realistic client question is "open and accepted, not rejected". The tenant-wide listing defaults to `open` (the work queue) when this is omitted; the document-scoped listing defaults to no filter, because a document view shows resolved notes too.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    ReadingProposalKindFilter:
      name: kind
      in: query
      required: false
      description: Filter to one proposal kind (`correction` | `low_confidence`). Omitted = both.
      schema:
        type: string
    ReadingProposalScenarioIdFilter:
      name: scenario_id
      in: query
      required: false
      description: Filter the tenant-wide listing to one scenario's documents.
      schema:
        type: string
        format: uuid
    ReadingProposalDocumentIdFilter:
      name: document_id
      in: query
      required: false
      description: Filter the tenant-wide listing to one document.
      schema:
        type: string
        format: uuid
    ReadingProposalCreatedAfterFilter:
      name: created_after
      in: query
      required: false
      description: Return only proposals created strictly after this instant — the polling filter. Pair it with the newest `created_at` you have already processed.
      schema:
        type: string
        format: date-time
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Client-generated key, unique per operation. First request executes and stores its response; a retry with the same key and an identical request body replays that response unchanged; the same key with a different body is rejected with 409 `urn:ug:error:idempotency_conflict`. Keys expire after 24h.
      schema:
        type: string
        maxLength: 255
  schemas:
    Tenant:
      type: object
      description: A tenant (the customer organization the caller belongs to).
      required:
        - id
        - slug
        - name
        - status
        - created_at
      properties:
        id:
          type: string
          format: uuid
        slug:
          type: string
          description: URL-safe unique identifier for the tenant.
        name:
          type: string
        status:
          type: string
          enum:
            - active
            - suspended
            - offboarding
        created_at:
          type: string
          format: date-time
        time_zone:
          type: string
          description: "The tenant's business clock as an IANA zone name (e.g. \"America/New_York\"), used to render instants for people rather than for machines. ABSENT means UTC — the behaviour every tenant had before this field existed. Wire timestamps are unaffected: they stay RFC 3339 UTC. This exists because a server-rendered artifact (the findings PDF, the export archive) has no browser to read a zone from and outlives the session that made it, so a filed document needs a stable stored zone rather than the reader's own."
          example: America/New_York
        vertical:
          type: string
          description: |-
            The workspace's DEFAULT kind of underwriting — the vertical a case or a question set is created on when nothing more specific says otherwise. ABSENT means the shipped default vertical, which is what most workspaces are on.

            It is a default and nothing more. It never constrains what this workspace may do, never filters what it may see, and is never consulted once a case exists: a case and a question set each carry their own `vertical`, fixed at creation, and those are the values that decide what may be underwritten against what. One workspace running several verticals side by side is ordinary, not an exception.

            Join it against `GET /v1/verticals` for a label — the absent value matches the catalog entry with no `key`. Set once at `POST /v1/tenants`; there is no route that moves it.
          example: contract_review
    TenantList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Tenant"
        next_cursor:
          type: string
    ProvisionTenantRequest:
      type: object
      description: The terms of a new tenant (POST /v1/tenants). `slug` and `name` are required; `vertical` is optional. There is no `status` — a provisioned tenant starts `active` — and no identity-provider or billing field, because those are set by their own later operations.
      required:
        - slug
        - name
      properties:
        slug:
          type: string
          minLength: 1
          maxLength: 63
          description: The tenant's URL-safe unique identity, and the natural key provisioning is convergent on. Lower-case letters, digits and hyphens.
          pattern: ^[a-z0-9]+(-[a-z0-9]+)*$
          example: northwind
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: The tenant's human-readable display name.
          example: Northwind Group
        vertical:
          type: string
          maxLength: 64
          description: "Optionally binds the tenant to a non-default vertical — the creation-time DEFAULT for the cases and question sets created under it (a default, never a run-time constraint). Omit for the shipped default vertical. Never a hardcoded industry value: the vertical is a parameter of platform onboarding, and the platform is industry-agnostic."
          example: equipment_finance
    BreakGlassRequest:
      type: object
      description: One operator's request to read this workspace's content, as the workspace's own administrators see it (threat-model /I /c). The window it opens is bounded to four hours by the database, is personal to the named operator, and every read taken under it writes a `breakglass.used` event on this workspace's audit chain.
      required:
        - id
        - status
        - reason
        - requested_duration_seconds
        - requested_at
        - operator
        - live
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum:
            - requested
            - approved
            - denied
            - revoked
          description: "`requested` awaits a decision; `approved` has a window (which may already have lapsed — see `live`); `denied` was refused and is final; `revoked` was an approval shut early. There is deliberately no `expired` status: the window's end is `expires_at` and expiry is derived from the clock, never a value a job has to write."
        reason:
          type: string
          description: Why the operator says they need to look, in their own words, at least 20 characters. This is the field to read before deciding.
        requested_duration_seconds:
          type: integer
          description: How long the window would stay open once approved, 1–14400 (four hours, the database's own ceiling).
        requested_at:
          type: string
          format: date-time
        operator:
          $ref: "#/components/schemas/BreakGlassParty"
          description: The LendArch operator who asked. A real, active internal staff identity — naming the human is the point of the record.
        approver_kind:
          type: string
          enum:
            - platform
            - tenant_admin
          description: Who approved. `tenant_admin` is this workspace's own administrator (this API); `platform` is a second LendArch operator under the two-person rule. Absent while the request is pending or if it was denied.
        approved_by:
          $ref: "#/components/schemas/BreakGlassParty"
          description: 'The person who approved, of whichever kind. Absent unless the grant was approved — AND absent even then if that account has since been removed (a purged operator workspace, a deleted administrator): the grant deliberately carries no foreign key to a users row, so the record survives the account. `approver_kind` present with `approved_by` absent is therefore a real, expected state and means "approved through that door by an account that no longer exists"; the id is in the `breakglass.approved` event in the audit export.'
        approved_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          description: When the window shuts. The database compares this against the clock on EVERY row an operator reads, so access stops at the operator's next statement — there is nothing to propagate and nothing to wait for.
        denied_at:
          type: string
          format: date-time
        denied_by:
          $ref: "#/components/schemas/BreakGlassParty"
          description: "The administrator who REFUSED the request. PRESENT ONLY ON THE RESPONSE TO `POST /break-glass/requests/{requestId}/deny` — the grant has no denier column (a denial records its instant and leaves the approval columns NULL), so every LATER read of the same request omits this field even though the request is denied. Do not build a screen that expects it from the list or the single GET: the permanent record of who said no is the `breakglass.denied` event on this workspace's own audit chain, which carries both the id and the address."
        revoked_at:
          type: string
          format: date-time
        live:
          type: boolean
          description: |-
            Whether the window is open RIGHT NOW — approved, not revoked, not yet expired. Computed at read time, never stored.

            COMPUTED ON THE SERVER'S CLOCK AT READ TIME, and the database enforces expiry against its own (`statement_timestamp`), so near zero the two can disagree by the skew between them and `true` can briefly outlive real access. Re-fetch rather than trusting a local countdown, and never treat `live: true` as permission to assert anything — the only thing that decides whether an operator can read a row is the database, per statement.
        remaining_seconds:
          type: integer
          description: How much longer a live window has, in whole seconds. Absent when `live` is false. Computed on the server at read time from the same clock as `live` — a UI counting it down locally drifts, so re-fetch near zero rather than deciding for yourself that the window shut.
    BreakGlassParty:
      type: object
      description: |-
        A person on a break-glass record. Deliberately NOT pseudonymous: the customer-readable record of who looked at their workspace names the human, and the addresses here are corporate rather than personal (threat-model B8/I, accepted residual).

        BOTH FIELDS ARE OPTIONAL AND EITHER MAY BE ABSENT, including on a `required` party such as `operator`: the grant carries no foreign key to a users row on purpose, so a purged operator workspace or a removed account leaves the RECORD intact and the LABELS empty. Render whichever field you have (`display_name` if present, else `email`), and where you have neither, say the account no longer exists rather than showing an empty name — the ids are in the `breakglass.*` events in the audit export. An object with no properties at all is a valid, expected value here; it is not an error and not a client bug.
      properties:
        display_name:
          type: string
        email:
          type: string
      example:
        display_name: Sam Okonkwo
        email: sam@lendarch.example
    BreakGlassRequestList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/BreakGlassRequest"
        next_cursor:
          type: string
    BreakGlassDecision:
      type: object
      description: The optional note an administrator may attach to an approval or a denial. It is recorded VERBATIM in the `breakglass.approved` / `breakglass.denied` audit event on this workspace's own chain — it is not a column on the grant and it does not come back on the response, because the grant's identity is frozen by the database once written and the audit chain is where the human explanation belongs.
      properties:
        note:
          type: string
          maxLength: 500
          description: Why you approved or refused, for the record. Optional; at most 500 characters.
      example:
        note: Confirmed with Sam on the support call; ticket 4821.
    TenantSettings:
      type: object
      description: The tenant's general configuration document (GET/PUT /tenant-settings) — the CUSTOMER-facing settings a tenant admin owns, stored in tenants.settings jsonb alongside (but never overlapping) the platform-owned keys. PUT full-replaces exactly the properties declared here; future general settings are added as sibling properties, not new endpoints.
      properties:
        time_zone:
          type: string
          maxLength: 128
          description: "The tenant's business clock as an IANA zone name — the same field Tenant.time_zone surfaces read-only to every member. ABSENT means unset: instants render in UTC, the behaviour every tenant had before the field existed. Validated against the IANA database on write; a change takes effect on the next data load (the SPA's tenant clock reads it from /tenants/me) and on the next rendered artifact — timestamps already stored or exported are unaffected, and wire timestamps stay RFC 3339 UTC."
          example: America/New_York
        notification_delivery_retention_days:
          type: integer
          minimum: 7
          maximum: 365
          description: "How many days the tenant's notification delivery records (the out-of-band webhook/email delivery attempts, including their stored payload) are kept before the daily retention sweep deletes them. ABSENT means unset: the platform default of 90 days applies. SHORTENING the window is destructive: delivery records now older than the new window are deleted on the next daily sweep and cannot be recovered. Audit events and underwriting data are unaffected — this governs only notification delivery records."
          example: 30
        embed_allowed_origins:
          type: array
          maxItems: 50
          items:
            type: string
            maxLength: 255
          description: |-
            The EXACT `https://` origins permitted to embed this tenant's audit UI in an iframe and — load-bearing — to obtain an embed session at the handoff exchange. ABSENT or empty means embedding is OFF: the fail-closed default, and an unlisted origin can frame pixels but never obtains a write-scoped session. Each entry is an origin only — scheme + host[:port], no path, query, fragment or userinfo — and NO WILDCARDS: a wildcard, a non-`https` scheme, or a malformed value is a 422, never stored, and the problem body names the offending index and the rule it broke. Origins are lower-cased and de-duplicated on write, so what is returned is the canonical form the exchange compares a host's measured origin against.

            Edited by a person at Admin -> Workspace settings -> Embedding in the app, or with `ug-admin embed allow-origin`; this property is the contract underneath both.
          example:
            - https://app.acme.example
            - https://underwriting.acme.example
        break_glass_require_tenant_approval:
          type: boolean
          description: |-
            Whether an operator's request to read THIS workspace's content must be approved by one of this workspace's OWN administrators (threat-model B8/I; the approval surface is `/break-glass/requests`). ABSENT or `false` is the shipped default: a request may be approved by a second LendArch operator under the two-person rule, OR by an administrator here — either door. `true` closes the operator door entirely: a platform-side approval is refused (`403`, `reason: tenant_approval_required`) and nobody at LendArch can read this workspace's documents until an administrator here says yes.

            TURNING IT ON IS A REAL TRADE, not a free hardening. Support work that needs to see your content stops until one of your admins approves it, so keep at least two active administrators and expect to be asked. It changes nothing about what is audited, and it never widens access: with it OFF an administrator here can still approve.

            The switch is read AT THE MOMENT OF APPROVAL, not when the request was filed, so turning it on does not grandfather requests already in flight past it.

            OMITTING THIS KEY ON A PUT TURNS IT OFF — resend `true` on every save while it is on. This endpoint full-replaces the document, so a save that carries only the time zone silently disables the control: GET, change your one field, PUT the whole thing back.
          example: true
        routing:
          $ref: "#/components/schemas/RoutingRules"
          description: "The tenant's PROGRAM SELECTION RULES — the ordered, deterministic rules that PROPOSE which program should govern a case, read by `GET /v1/scenarios/{scenarioId}/routing-proposal`. ABSENT or an empty `rules` list means no selection rules are configured, which is a first-class state and the shipped default: the proposal read simply answers `matched: false`, the UI shows no routing affordance, and choosing a program stays a purely manual act — exactly the system you have today. Full-replace like every other property here: omitting `routing` DELETES the stored rules."
    RoutingRules:
      type: object
      description: |-
        An ordered set of deterministic program selection rules.

        ORDER IS THE SEMANTICS: the rules are evaluated in the order given and the FIRST rule whose every condition holds decides. Its position in this list is the `rule_index` a proposal reports and an accepted proposal's audit event records, which is why order is part of the contract and not an implementation detail. Put your most specific rules first.

        RULES PROPOSE; THEY NEVER BIND. Nothing here is applied automatically, ever: a matched rule produces a proposal a human reads, with the facts it matched on cited so the reasoning is visible, and the only way a program is actually set is a person creating a case with it or deriving one (`POST /v1/scenarios/{scenarioId}/derive`). No LLM participates — routing decides which rulebook judges a case, and everything on that path is deterministic and citation-backed.
      required:
        - rules
      example:
        rules:
          - when:
              - fact: some.canonical.key
                op: eq
                value: some_value
            program: some_program
          - when:
              - fact: some.canonical.key
                op: eq
                value: another_value
            program: another_program
      properties:
        rules:
          type: array
          maxItems: 50
          description: |-
            The ordered rules. An empty list is valid and means the same as omitting `routing` entirely: no rules configured.

            The schema `example` above is a complete, PUT-able document in the commonest shape there is: one rule per program, ordered, disjoint on one canonical key — copy it, substitute your own key, literals and program names, and send it as this request's `routing` property.
          items:
            $ref: "#/components/schemas/RoutingRule"
    RoutingRule:
      type: object
      description: "One selection rule: a conjunction of conditions and the program to propose when ALL of them hold. To express an alternative, write a second rule — two rules read better on an audit trail than one compound condition, and the first-match order makes the precedence explicit."
      required:
        - when
        - program
      properties:
        when:
          type: array
          minItems: 1
          maxItems: 10
          description: "Every condition must hold for this rule to fire. At least one is required: a rule with no conditions would propose a program having compared nothing, and a proposal with no cited facts is a proposal with no grounding. There is deliberately no catch-all rule and no tenant default program."
          items:
            $ref: "#/components/schemas/RoutingCondition"
        program:
          type: string
          maxLength: 64
          pattern: ^[a-z][a-z0-9_]*$
          description: 'The program this rule proposes — your own value, with no catalog to register it in. Required and non-empty: the only door that applies a proposal is a derive, and a derive names the program it moves a case to, so a rule proposing "no program distinction" would be one nobody could act on.'
    RoutingCondition:
      type: object
      description: |-
        One comparison against the facts a case has PROMOTED — canonical keys, never raw source paths, so a rule survives a re-parse by a different adapter and the proposal's citation stays resolvable.

        A condition holds when AT LEAST ONE fact under `fact` satisfies it, and that fact is the one cited. Any-of rather than all-of because a case's promoted facts are a multiset — several parties each promote a role, several accounts each promote a balance — so "every fact under this key must satisfy it" would make a second party silently break a rule the first one matched.

        A comparison that cannot be made does not fail the request: the condition simply does not hold, its rule is skipped, and the next rule is tried. Routing is optional enrichment; the honest answer to "these rules cannot decide" is no proposal, never an error on a read.
      required:
        - fact
        - op
      properties:
        fact:
          type: string
          maxLength: 120
          pattern: ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$
          description: "The canonical key to compare, e.g. `some.canonical.key`. Validated for SHAPE only, deliberately not for membership of your vertical's vocabulary pack: a tenant's own promotion-mapping rules may promote keys the shipped pack does not carry, so vocabulary membership is not a validity criterion for a selection rule. The cost of that latitude is that a mistyped key simply never matches — the proposal read reports how many rules were evaluated so a rule set that never fires is visible rather than mysterious."
        op:
          type: string
          enum:
            - eq
            - in
            - exists
            - gte
            - lte
          description: |-
            The comparison.

            `eq` — a fact under `fact` equals `value` exactly.

            `in` — a fact under `fact` equals any member of `values`.

            `exists` — the case promoted ANY fact under `fact`, whatever its value. Takes neither `value` nor `values`.

            `gte` / `lte` — a fact under `fact` is ordered at or after / at or before `value`. Ordering is defined for exactly the two normalized forms that HAVE an order: a plain decimal number, compared numerically and exactly; and a `YYYY-MM-DD` date, for which lexicographic order IS chronological. Anything else is incomparable on purpose — asking whether one enum value is "at least" another has no answer, and inventing one would route cases by a coincidence of spelling. `value` must itself be orderable (422 otherwise); a fact whose value is not simply fails to satisfy the condition.
        value:
          type: string
          minLength: 1
          maxLength: 200
          description: "The literal for `eq`, `gte` and `lte`; required for those and refused for the others (a `value` under `in`, or under `exists`, is a 422 rather than a silently ignored field — someone who wrote one believed they were getting a different comparison). Non-empty when present: the server refuses an empty literal, so the bound is here too and your own schema validator catches it first."
          example: some_value
        values:
          type: array
          minItems: 1
          maxItems: 50
          items:
            type: string
            minLength: 1
            maxLength: 200
          description: The literal set for `in`; required for `in`, refused for every other operator, at least one member, and no member may be empty — all three refused by the server, and all three stated here so a client-side schema validator catches them without a round trip.
          example:
            - some_value
            - another_value
    MintEmbedHandoffRequest:
      type: object
      description: Names the subject, the single case AND the origin a host backend is minting a handoff for.
      required:
        - subject_email
        - scenario_id
        - host_origin
      properties:
        subject_email:
          type: string
          minLength: 3
          maxLength: 320
          description: "The UG user this handoff is for, as an email address. Matched case-folded to an ACTIVE users row at the EXCHANGE — never here — so a handoff for an unknown or inactive address is legal to mint and simply refused when exchanged. Not an authentication input on its own. Validation here is minimal (the same posture as an invitation's email): your directory is the authority on what its addresses look like, and this value is matched, never used to authenticate."
          example: analyst@acme.example
        scenario_id:
          type: string
          minLength: 1
          maxLength: 64
          description: The single case this handoff opens, as a scenario id — a host platform's button that opens one case mints a handoff for it. The minted session is READ-ONLY and confined to exactly this scenario. Verified to exist in the tenant at the EXCHANGE (not here), so a handoff for a scenario that is not the tenant's is legal to mint and simply refused. Must be a UUID; a malformed value is a 422.
          example: 6f9619ff-8b86-d011-b42d-00c04fc964ff
        host_origin:
          type: string
          maxLength: 255
          description: The EXACT https:// origin your page will frame us from — scheme plus host and optional port, no path, query, fragment, userinfo or wildcard. Your backend knows this; the coupon is BOUND to it, and the exchange requires the origin the frame measures from the postMessage event to equal this value AND this value to be in your tenant's allowlist. So a coupon that leaks is usable only from the one origin you named for it. A malformed value is a 422 here; whether the origin is allowlisted is still checked only at the exchange (this endpoint deliberately verifies nothing about your tenant's configuration).
          example: https://app.acme.example
    EmbedHandoff:
      type: object
      description: A minted single-use handoff coupon.
      required:
        - handoff
        - expires_at
      properties:
        handoff:
          type: string
          description: The opaque, single-use handoff token — shown ONLY in this response and stored hashed at rest, so a lost coupon is re-minted, never recovered. The host page posts this into the embed frame; it must never be persisted or exposed to a browser beyond that hop.
        expires_at:
          type: string
          format: date-time
          description: When the handoff lapses (≈60 seconds out). A later exchange refuses a lapsed coupon.
    CreateEmbedSessionRequest:
      type: object
      description: The handoff and measured origin the embed frame exchanges for a session.
      required:
        - handoff
        - host_origin
      properties:
        handoff:
          type: string
          description: The single-use handoff token the host page posted into the frame.
        host_origin:
          type: string
          maxLength: 255
          description: The origin the frame measured from the postMessage event's unforgeable `origin`. Checked against the tenant's allowlist; an unlisted origin is refused (and the handoff still consumed).
          example: https://app.acme.example
    EmbedSession:
      type: object
      description: A minted read-only, single-scenario in-frame embed session.
      required:
        - token
        - expires_at
        - scenario_id
      properties:
        token:
          type: string
          description: "The opaque embed session bearer — shown ONLY in this response and stored hashed at rest. The frame sends it as `Authorization: Bearer` on subsequent calls; the authenticator resolves it as the READ-ONLY `embed` principal, which may GET only its bound scenario and that scenario's sub-resources. Never placed in localStorage — it lives in the frame's memory only."
        expires_at:
          type: string
          format: date-time
          description: "When the session lapses (≈30 minutes out). Renewal is a fresh host handshake and never a self-refresh: the frame cannot mint anything (minting is server-to-server, API-key gated) and a refusal is terminal. The host's BACKEND mints a new handoff and the host page posts it into a reloaded frame."
        scenario_id:
          type: string
          format: uuid
          description: The single case this session may read — echoed so the frame knows which scenario it is confined to. Every other scenario, and every non-GET, is refused.
    TenantMe:
      type: object
      description: The authenticated caller's tenant plus their identity context.
      required:
        - tenant
        - actor_type
      properties:
        tenant:
          $ref: "#/components/schemas/Tenant"
        actor_type:
          type: string
          description: The kind of credential the caller authenticated with.
          enum:
            - user
            - api_key
        role:
          type: string
          description: "The caller's role; present only for user principals. Hierarchical (platform ⊇ admin ⊇ reviewer ⊇ viewer): viewer is the read-only member baseline; reviewer adds the evaluate / review-decisions / override surface; admin adds tenant config (the CUSTOMER-facing tenant-admin ceiling — notification deliveries); platform is the INTERNAL-ONLY operator tier above admin: LLM spend/ cost, cache/ops metrics, and the tenant evaluation-policy guardrail knobs. Customer-tenant admins never carry `platform`."
          enum:
            - platform
            - admin
            - reviewer
            - viewer
        auth:
          $ref: "#/components/schemas/TenantMeAuth"
          description: How this caller signs in — present ONLY for `user` principals, the same rule as `role`. An API key is not a human identity and has no credential experience to describe, so the object is omitted for `api_key` callers rather than filled with a meaningless value.
    TenantMeAuth:
      type: object
      description: |-
        Where the caller's sign-in credentials are managed. Two kinds of user exist side by side and always will: a LOCAL user, whose password and second factors live in the sign-in service we operate, and a FEDERATED user, asserted by their own organization's identity provider. A tenant may hold both at once — federation is never exclusive — so this describes THE CALLER, not the tenant. Resolved server-side from our own records: it is deliberately NOT derived from the access token, because the token cannot tell the two apart (the `amr` claim that would say so is absent from access tokens entirely). A client that infers this by decoding a token will be wrong; read it here.
        NOT here, deliberately, and recorded so the question is not reopened: the caller's identity-provider ORGANIZATION IDENTIFIER. The server cross-checks it on every request, but it selects nothing, grants nothing, and appears in no other request or response — publishing it would put an internal identifier on the wire in exchange for no capability. Neither is there an MFA or assurance level: access tokens do not carry one, so any value here would be invented.
      required:
        - identity_source
        - credential_management
      properties:
        identity_source:
          type: string
          description: "`local` — the caller's credentials are held by the sign-in service we operate. `federated` — the caller is asserted by their own organization's identity provider, and we hold no credential for them."
          enum:
            - local
            - federated
        credential_management:
          type: string
          description: "Who runs credential changes (password, second factors) for this caller — the one thing an integrator or a UI actually branches on. `hosted` — our own branded sign-in pages; offer the password / second-factor hand-off. `external` — the caller's own organization, upstream; offer nothing, and name who manages it instead. Today `local` implies `hosted` and `federated` implies `external`, but they are separate fields on purpose: branch on this one, so a future arrangement that splits them does not break you. When the source cannot be determined this is `hosted` — the deliberate fail-open direction, because the cost of wrongly hiding a password hand-off is a user locked out of their own password."
          enum:
            - hosted
            - external
        connection_name:
          type: string
          minLength: 1
          description: |-
            Display name of the identity-provider connection that asserted a federated caller, as their administrator named it ("Acme Entra ID"). For display only — never an identifier to match on. ABSENT for every `local` caller, and absent for a `federated` caller whose connection could not be resolved to a name: absent never means "no connection", it means "no name to show". Treat it as permanently optional — a UI must render a federated caller correctly with this field missing, because `credential_management` is the field to branch on and this one is only the label beside it.
            The same name, plus the rest of the tenant's federation configuration, is readable by an admin at /v1/idp-connection.
    Invitation:
      type: object
      description: |-
        A recorded authorization for one email address to become a user of this workspace at one role.
        THERE IS NO CREDENTIAL ON THIS SCHEMA AND THERE IS NOTHING TO FORWARD. An invitation is matched, at the invited person's first sign-in, against an email address their identity provider reports as verified — so it qualifies somebody who has already authenticated rather than granting anybody a way in. That is structural, not a filtering rule: the stored row has no token column for this schema to omit.
      required:
        - id
        - email
        - role
        - status
        - provisioning
        - expires_at
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: Stable identifier for the invitation; addresses this resource.
        email:
          type: string
          maxLength: 254
          description: The address authorized, as it was submitted. Matched case-folded, so the capitalisation stored here is cosmetic.
          example: sam@northwind.example
        role:
          type: string
          description: The role the invited person is admitted at. `platform` is absent because it is our internal operator tier and cannot be granted by a customer's administrator; the storage layer refuses the value independently of this API.
          enum:
            - admin
            - reviewer
            - viewer
        status:
          type: string
          description: |-
            The one field to branch on. It is DERIVED from the timestamps below at read time, which is why an invitation can move from `pending` to `expired` with nothing having been written.
            `pending` — waiting. The person has not signed in yet, and will be admitted when they do.
            `accepted` — used. Somebody signed in with a verified address that matched, and `consumed_by` names the user created. Terminal, and one invitation admits exactly one person.
            `expired` — `expires_at` has passed and nobody used it. Nothing was granted. Invite the address again to issue a fresh one.
            `revoked` — an administrator withdrew it before it was used, or a fresh invitation for the same address superseded a lapsed one. Terminal.
          enum:
            - pending
            - accepted
            - expired
            - revoked
        invited_by:
          type: string
          format: uuid
          description: |-
            The administrator accountable for this authorization — a user id from `GET /v1/users`.

            ABSENT on exactly one kind of invitation: the one that admits a brand-new workspace's FIRST administrator, written through `POST /v1/tenants/{tenantId}/invitations` before any member exists to name. Absent therefore means "invited during onboarding, by us", not "unattributed" — the accountable operator is recorded on the `invitation.created` audit event, which is where it belongs, because that person's account lives in a different workspace and this field may only name a member of THIS one.
        expires_at:
          type: string
          format: date-time
          description: When this authorization lapses if unused. Defaults to seven days after creation.
        consumed_at:
          type: string
          format: date-time
          description: When somebody signed in and was admitted by this invitation. ABSENT unless `status` is `accepted`.
        consumed_by:
          type: string
          format: uuid
          description: The user this invitation created — the permanent answer to "who authorized this person's access". ABSENT unless `status` is `accepted`.
        revoked_at:
          type: string
          format: date-time
          description: When it was withdrawn. ABSENT unless `status` is `revoked`.
        provisioning:
          $ref: "#/components/schemas/InvitationProvisioning"
        created_at:
          type: string
          format: date-time
    InvitationProvisioning:
      type: object
      description: |-
        Whether a sign-in account was created for this invited person, so the sign-in service could email them a "set your password" link.
        READ IT TO ANSWER ONE QUESTION: has this person been emailed, or does somebody need to tell them? It is NOT the invitation's state — `status` above is — and it is NOT an authorization. An invitation admits the person it names at their first verified sign-in whatever this object says: the authorization is the invitation, and the account is a convenience so they do not need a colleague to walk them in.
        ALWAYS PRESENT, and `none` in every deployment that does not create accounts (see `capabilities.self_serve_provisioning_available` on `GET /v1/idp-connection`). `none` is a healthy, supported state, not a failure — it means the invitation is delivered by whoever wrote it.
        THERE IS NO ACCOUNT IDENTIFIER HERE AND THERE WILL NOT BE. The sign-in service's own id for the account selects nothing you can act on, appears in no request, and is provider-internal — the same call this API makes for an identity-provider connection's internal reference.
      required:
        - status
      properties:
        status:
          type: string
          description: |-
            `none` — no account was attempted. The DEFAULT and the only value in a deployment that does not create them.
            `pending` — queued. The email has not been sent yet; poll or refresh.
            `provisioned` — an account WAS created and the sign-in service emailed the person their set-your-password link. `POST /v1/invitations/{invitationId}/resend` can send it again. This is a HISTORICAL record, not a statement that an account is currently usable: withdrawing the invitation (`DELETE /v1/invitations/{invitationId}`) removes a never-initialized account or disables an initialized one, and `provisioning.status` stays `provisioned` as the record that it happened. Read the invitation's own `status` for whether the person can still get in.
            `failed` — the attempt failed and `error` says why. THE INVITATION IS STILL GOOD: it will admit them the moment they sign in with a verified address that matches, so nothing needs re-authorizing.
            HOW TO ACTUALLY RETRY, because the obvious move does not work. A plain re-POST of the same address and role while the invitation is still live is the idempotent case — `200`, the same row back, nothing written and NOTHING RE-ENQUEUED — so a "Try again" button wired to `POST /v1/invitations` does nothing at all. `POST /v1/invitations/{invitationId}/resend` does not help either: it requires `provisioned`, and there is no account here to send anything about. The working retry is `DELETE /v1/invitations/{invitationId}` and then `POST /v1/invitations` again: that is a fresh row, which provisions from scratch. (Or get them a credential another way — the invitation admits them regardless.)
          enum:
            - none
            - pending
            - provisioned
            - failed
        idp_account_created_at:
          type: string
          format: date-time
          description: |-
            When the sign-in account was created. ABSENT unless `status` is `provisioned`.
            It is NOT when the person set their password — we are not told that. The reliable signal that they finished is the invitation's own `status` becoming `accepted`, which happens on their first sign-in.
        error:
          type: string
          maxLength: 300
          description: |-
            Why the last attempt failed. Cleared when a later attempt succeeds, so it never explains a state this invitation has left.
            USUALLY IT SITS BESIDE `failed`, meaning nobody was emailed. IT CAN ALSO APPEAR BESIDE `provisioned`, and that case is a WITHDRAWAL that could not be finished: the invitation is withdrawn and admits nobody — that part always takes effect — but the sign-in account it had created could not be disabled or removed. The sentence says which of the two happened, so read it rather than inferring it from `status`.
            WHAT IT ACTUALLY CONTAINS, so you know what you are rendering: our own sentence naming WHICH act could not be done — create the account, send the email again, or withdraw the account — followed by the sign-in service's own human-readable message, the whole thing truncated to 300 characters.
            IT IS SANITIZED BY CONSTRUCTION and that is a guarantee rather than a habit: one line, no newlines, no request path, no URL, no account identifier, and none of the sign-in service's internals — not its raw JSON body, not its error ids, not its product name. What is left of the tail is its own message text: useful to show an administrator, not something to parse or match on. Branch on `status`.
    InvitationList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Invitation"
        next_cursor:
          type: string
    CreateInvitationRequest:
      type: object
      required:
        - email
        - role
      properties:
        email:
          type: string
          minLength: 3
          maxLength: 254
          description: "The address to authorize. It must be the address the person's identity provider will report for them, because that is what it is matched against — an address that matches nothing simply never admits anybody. Validation here is deliberately minimal (one `@`, something either side, no spaces): your identity provider is the authority on what its addresses look like, not us."
          example: sam@northwind.example
        role:
          type: string
          description: The role to admit them at. `platform` is not grantable — see the `role` field on `Invitation`.
          enum:
            - admin
            - reviewer
            - viewer
        expires_in_days:
          type: integer
          minimum: 1
          maximum: 30
          default: 7
          description: "How long the invitation stays usable. Seven days by default: one working week survives the ordinary case (invited Friday, starts Monday, signs in Tuesday) while bounding the failure mode that matters, which is an authorization outliving the reason it was granted. Thirty days is the cap; re-inviting is one idempotent call, so a short window is cheap to recover from and a long one is expensive to regret."
    User:
      type: object
      description: One person with access to this workspace.
      required:
        - id
        - display_name
        - role
        - status
        - identity_source
        - created_at
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          description: |-
            The person's address as recorded when their account was created here. It is a display and correspondence attribute, NOT the identity key — a person is identified by the subject their identity provider issues, so an address that changes upstream does not change who this row is.

            PRESENT FOR ADMINISTRATORS ONLY, and ABSENT for every other role. This list is readable by any member, including `viewer` — the role you would grant an outside reviewer — and a colleague's address is the one field here that is useful to somebody who should not have it. Everywhere else this API publishes a `*_display_name` and no address. Branch on presence: absent means "you are not an administrator of this workspace", never "this person has no address".
        display_name:
          type: string
          maxLength: 200
          description: |-
            What to show for this person. May be empty for an account created before a name was known.

            At most 200 characters, and that is a bound we keep rather than one we merely publish. READ-ONLY over this API — no request body here accepts a person's display name, so this is not a `422` you can provoke; it is enforced on the paths that DO write the field. A name supplied to us directly is refused if it exceeds the bound, and one arriving from a customer's own identity provider — which we do not control and must not lock somebody out over — is TRUNCATED to it. So a reader can size a column to 200 and be right.
        role:
          type: string
          description: "`viewer` reads. `reviewer` adds the evaluate and review surface. `admin` adds workspace configuration — the customer-facing ceiling. `platform` is our own internal operator tier and appears only on accounts we operate; it is published here so a client never meets an unknown value, never as a role a customer's administrator can grant."
          enum:
            - platform
            - admin
            - reviewer
            - viewer
        status:
          type: string
          description: |-
            `active` — can sign in. `suspended` — cannot, and is meant to be restored. `deactivated` — cannot, and is not.
            An administrator changes this field with `PATCH /v1/users/{userId}`, and the change takes effect on the person's next request to us: this API re-reads the status on every call, so a person set away from `active` is refused immediately for anything they do against us. (A token already in their browser could still be accepted by some OTHER service for the few minutes until it expires, because that check happens offline — but nothing of ours accepts them once they are not active.)
          enum:
            - active
            - suspended
            - deactivated
        identity_source:
          type: string
          description: |-
            `local` — this person's credentials live in the sign-in service we operate; they have a password there. `federated` — their own organization's identity provider asserts them, and we hold no credential for them.
            It comes from our own record of how the account was created, and is never inferred from the email domain. A claimed domain routes a login prompt and grants nothing, so a workspace can hold local accounts at a domain it has also claimed.
          enum:
            - local
            - federated
        created_at:
          type: string
          format: date-time
    UserList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/User"
        next_cursor:
          type: string
    UserUpdate:
      type: object
      description: The parts of a person's access an administrator may change. Both fields are optional and independent — send `status`, `role`, both, or an empty object — and an absent field is left exactly as it was.
      properties:
        status:
          type: string
          description: "`active` restores access, `suspended` pauses it, `deactivated` removes it. The account is never deleted; the change is recorded and takes effect on the person's next request."
          enum:
            - active
            - suspended
            - deactivated
        role:
          type: string
          description: The person's role. `platform` is absent on purpose — it is our internal operator tier and cannot be granted through this API.
          enum:
            - admin
            - reviewer
            - viewer
    IdpConnectionConfig:
      type: object
      description: "A tenant's whole federation posture in one document: the identity provider it federates from (if any), the email domains it has claimed, and what this deployment is able to do with them. One document rather than separate collections because the parts are only meaningful together — a claimed domain routes people at that domain to the connection, and neither half answers \"how does this tenant sign in?\" on its own."
      required:
        - claimed_domains
        - capabilities
      properties:
        connection:
          $ref: "#/components/schemas/IdpConnection"
          description: The tenant's identity-provider connection. ABSENT means this tenant does not federate — a permanent, fully-supported state, not a pending one — so branch on presence and render the absence as "not configured" rather than as a failure. A tenant has at most one connection on this surface; if more than one were ever recorded, this reports the oldest, which is a stable answer where "one of them" would not be.
        claimed_domains:
          type: array
          description: "Every email domain this tenant has claimed, oldest first. ALWAYS PRESENT and may be empty. Unverified claims are included on purpose: a claim waiting on DNS is exactly what an administrator needs to see, and hiding it would make a pending domain indistinguishable from one nobody ever asked for."
          items:
            $ref: "#/components/schemas/IdpClaimedDomain"
        capabilities:
          $ref: "#/components/schemas/IdpConnectionCapabilities"
    IdpConnectionCapabilities:
      type: object
      description: |-
        What this deployment can actually do, as opposed to what you have configured. ALWAYS PRESENT.
        READ IT BEFORE BELIEVING A SETTING. A capability reported false means the behaviour is unavailable here regardless of how the connection is configured — your setting is recorded and honoured the moment the capability returns, and until then it does nothing. The reasons a capability is false are ours to fix and are deliberately not published: they describe our infrastructure rather than your configuration, and there is nothing you could do with them.
      required:
        - domain_admission_available
        - self_serve_registration_available
        - self_serve_provisioning_available
      properties:
        self_serve_registration_available:
          type: boolean
          description: |-
            Whether this deployment lets you register a connection and claim a domain YOURSELF — `PUT /v1/idp-connection` and `POST /v1/claimed-domains`.
            TRUE is the ordinary answer. FALSE means both of those routes answer `503` however well-formed the request is, and setting single sign-on up is something we do WITH you: contact your LendArch representative or support@lendarch.com. Read it before drawing a registration form. The form asks for a client secret, and a secret typed into a form that could only ever have failed is a secret the customer must now rotate at their own provider — which makes this the one capability whose absence costs something to discover late.
            It reports WHETHER, never why: the cause is our infrastructure, you can do nothing with it, and naming it would put our deployment's internals on your document to no purpose.
        domain_admission_available:
          type: boolean
          description: |-
            Whether automatic admission at a verified claimed domain can operate in this deployment.
            TRUE is the ordinary answer and means `jit_enabled` does what it says. FALSE means the check that automatic admission depends on cannot be performed here, so `jit_enabled: true` admits NOBODY: people arriving at a verified domain are refused with `urn:ug:error:not_admitted` and must be invited instead. This is published because the screen where somebody switches automatic admission on is the only honest place to say whether it works — the alternative is an administrator who turns it on, sees a healthy document, and hears about it from the colleagues who cannot sign in.
        self_serve_provisioning_available:
          type: boolean
          description: |-
            Whether inviting somebody also creates them a sign-in account, so the sign-in service emails them a "set your password" link.
            TRUE means `POST /v1/invitations` returns a row whose `provisioning.status` moves to `pending` and then `provisioned`, and that `POST /v1/invitations/{invitationId}/resend` works. FALSE means every invitation reads `provisioning.status: none`, the resend route answers `503`, and the invited person needs to be told how to sign in by whoever invited them — everything else about invitations is identical, and Door 1 admits them exactly the same way either direction.
            READ IT BEFORE WRITING THE COPY on an invite form. The two states need different sentences ("we'll email them" versus "you'll need to send them the link"), and a form that promises an email this deployment cannot send produces a colleague who waits for nothing.
            IT IS PER-WORKSPACE, not only per-deployment. A deployment that provisions still cannot create an account for a workspace whose setup is unfinished on our side, and while that is true `POST /v1/invitations` answers `409` — so this reads FALSE there too, with `self_serve_provisioning_unavailable_reason` saying which of the two FALSE states you are in.
            It reports WHETHER, never why — the reason field beside it is the one narrow exception, and it distinguishes two CUSTOMER-visible states rather than naming any of our infrastructure.
        self_serve_provisioning_unavailable_reason:
          type: string
          enum:
            - not_offered
            - workspace_setup_incomplete
          description: |-
            Which kind of FALSE `self_serve_provisioning_available` is. Present only when that field is false; absent when it is true.
            `not_offered` — this deployment does not create sign-in accounts at all. A healthy, supported configuration: invitations work exactly as they always have and the invited person is told how to sign in by whoever invited them.
            `workspace_setup_incomplete` — this deployment DOES create sign-in accounts, but this workspace is not finished being set up on our side, so there is nowhere to create one. Until that is done `POST /v1/invitations` answers `409` and no invitation can be written at all. Nothing the workspace's own administrators can configure fixes it: the remedy is to contact your LendArch representative or support@lendarch.com.
            WHY THIS ONE FIELD SAYS WHY when nothing else on this object does. The rule its neighbours keep — publish whether, never why — rests on the cause being our infrastructure and there being nothing you could do with it. Here the two false states need OPPOSITE screens: one is "nothing is wrong, write to your colleague yourself", the other is "this will be refused until you talk to us", and a client holding only the boolean would draw the first for the second and send an administrator into a `409` they were told nothing about.
    IdpConnectionRegistration:
      type: object
      description: |-
        An OIDC application at YOUR identity provider, as `PUT /v1/idp-connection` needs it. The three required fields are exactly what your provider hands you when you create the application; the two optional ones are the admission posture, and both default to refusing.

        THE CLIENT SECRET IS PASSED THROUGH, NEVER KEPT. It reaches our sign-in service, which needs it for the handshake with your provider, and it is written to no column, no response, no audit record and no log here. We cannot read it back to you afterwards and we cannot rotate it — see the endpoint description.
      additionalProperties: false
      required:
        - name
        - issuer
        - client_id
        - client_secret
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name, as your administrators and your people should see it ("Acme Entra ID"). It is a label; nothing keys on it, and `PATCH` can change it later.
        issuer:
          type: string
          format: uri
          maxLength: 2048
          description: Your provider's OIDC issuer URL — the one whose `/.well-known/openid-configuration` describes the application. Must be absolute and `https`. This is the field that says WHICH identity provider asserts your people, which is why no other endpoint can change it.
        client_id:
          type: string
          minLength: 1
          maxLength: 512
          description: The application's client id at your provider. Not a secret.
        client_secret:
          type: string
          minLength: 1
          maxLength: 2048
          format: password
          writeOnly: true
          description: "The application's client secret at your provider. WRITE-ONLY in the strongest sense available: no response schema in this contract has a field it could be returned in, and there is no column for it in our database. Send it over TLS and keep your own copy — we cannot show it to you again."
        jit_enabled:
          type: boolean
          default: false
          description: "Whether to admit an unrecognized person automatically when they arrive through this connection at one of your VERIFIED claimed domains. Defaults to `false`, which is the safe posture to register with: your people can sign in, and each of them still needs an invitation. It admits nobody until you also claim a domain and it verifies."
        jit_default_role:
          type: string
          enum:
            - admin
            - reviewer
            - viewer
          default: viewer
          description: The role an automatically-admitted person receives. `viewer` is the default and the recommendation — the least an account can carry, and raisable afterwards. `platform` is not in this list and is refused with a 422.
    IdpClaimedDomainClaim:
      type: object
      description: One email domain to claim, as `POST /v1/claimed-domains` needs it.
      additionalProperties: false
      required:
        - domain
      properties:
        domain:
          type: string
          minLength: 1
          maxLength: 253
          description: The email domain, without an `@` and without a scheme — `acme.example`, not `@acme.example` and not `https://acme.example`. Case is not significant, and an internationalized name is folded to its ASCII form before anything is stored, so `bücher.example` and `xn--bcher-kva.example` are one domain rather than two.
        regenerate_challenge:
          type: boolean
          default: false
          description: |-
            Issue a FRESH DNS record for a domain that is already waiting, and INVALIDATE the outstanding one. Send this only when you have lost the record from an earlier response — publishing the old value will stop working the moment you do.

            The default is `false` and that is what makes re-checking safe: our sign-in service mints a new token every time one is asked for and accepts only its latest, so a re-check that regenerated by default would break the record you published while DNS was still propagating. A domain being registered for the FIRST time always receives a challenge — there is nothing to invalidate — whatever this says.
    IdpClaimedDomainClaimResult:
      type: object
      description: What one claim attempt achieved. Read `claimed` for the outcome and `challenge` for what to do next; the HTTP status says the same thing (200 already yours, 201 proved and claimed, 202 waiting).
      additionalProperties: false
      required:
        - domain
        - claimed
        - verified_without_proof
      properties:
        domain:
          type: string
          description: The domain as it was normalized and acted on — lower-cased and folded to its ASCII form. Echoed rather than your spelling, because this is the name the DNS record hangs off and the name that will be stored.
        claimed:
          type: boolean
          description: "True when this workspace holds the claim: either it was recorded on this call (201) or it already was (200). False means nothing is recorded and nothing routes yet."
        verified_without_proof:
          type: boolean
          description: |-
            THE ONE FIELD THAT NEEDS EXPLAINING, and it appears with `claimed: false`. It means our sign-in service already considered this domain verified, but NOTHING IN THIS REQUEST PROVED IT — no challenge was issued and none was checked. We will not record a claim on a verification we cannot account for, because claiming a domain routes real people's sign-in prompts and, with automatic admission on, can let them in.

            It covers two situations a caller cannot tell apart and does not need to: the domain was verified at some earlier time (possibly before our sign-in service validated domains at all, when it verified nothing), or it was verified moments ago by something other than this request. Both mean the same thing — we hold no evidence of our own — which is why the field is named for the missing proof rather than for when the verification happened.

            What to do: remove the domain, or un-verify it, at our sign-in service — contact us and we will do it with you — then send this request again to force a fresh challenge you can satisfy.
        claim:
          $ref: "#/components/schemas/IdpClaimedDomain"
        challenge:
          $ref: "#/components/schemas/IdpDomainChallenge"
    IdpDomainChallenge:
      type: object
      description: |-
        The DNS record that proves you control the domain. Publish it exactly as given, wait for it to propagate, then send `POST /v1/claimed-domains` again for the same domain.

        THE RECORD NAME IS NOT OURS TO CHOOSE. It is the name our sign-in service looks for; a prettier one would simply not be found. Treat all three values as opaque strings to copy, and re-read them on every response rather than caching them.
      additionalProperties: false
      required:
        - record_type
        - record_name
        - record_value
      properties:
        record_type:
          type: string
          enum:
            - TXT
          description: The DNS record type to create.
        record_name:
          type: string
          description: The fully-qualified record name, e.g. `_zitadel-challenge.acme.example`. Some DNS interfaces want the host part only (`_zitadel-challenge`) with the zone appended for you — check which yours expects.
        record_value:
          type: string
          description: The record's value. It is single-purpose and short-lived and it is not a credential — publishing it in DNS is the entire point — but it is only good for this domain and this check.
    IdpConnectionUpdate:
      type: object
      description: |-
        The settings of an existing connection that a tenant administrator may change. EVERY FIELD IS OPTIONAL; an absent field is left alone, and an empty object is a valid no-op request.
        NOTHING HERE CAN RE-POINT A CONNECTION. There is no issuer, no client id and no client secret on this schema — not filtered out, never present — because those three say WHICH identity provider asserts your people, and changing that is a new connection rather than an edit. Rotating a client secret is likewise not here: the secret lives at your identity provider, we hold no copy of it, and so there is nothing on our side to rotate. Both are conversations with us.
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name, as your administrators should see it ("Acme Entra ID"). Cannot be blanked — send no `name` at all to leave it alone. It is a label and nothing keys on it.
        status:
          type: string
          enum:
            - active
            - disabled
          description: "`active` makes the connection available at sign-in; `disabled` stops the NEXT sign-in through it. Read the `status` description on the connection schema before using this to remove somebody's access — it deprovisions nobody and does not end sessions already in progress."
        jit_enabled:
          type: boolean
          description: Whether to admit an unrecognized person automatically when they arrive through this connection at one of your VERIFIED claimed domains. Turning it on is a standing authorization for accounts you have not individually named; turning it off stops future automatic admissions and removes nobody. Check `capabilities.domain_admission_available` on the response — where that is false, `true` here admits nobody.
        jit_default_role:
          type: string
          enum:
            - admin
            - reviewer
            - viewer
          description: "The role an automatically-admitted person receives. `viewer`, the read-only baseline, is the default and the recommendation: it is the least an account can carry and an administrator can raise it afterwards. `platform` is not in this list and is refused with a 422 — it is our internal operator tier and no automatic admission may ever reach it."
    IdpConnectionVerification:
      type: object
      description: "What the identity provider currently reports about this tenant's connection, compared against the settings it was registered with. Produced by `POST /v1/idp-connection:verify`. It is a report: findings appear in `drift` and the response is still a 200."
      required:
        - checked_at
        - provider_consulted
        - drift
        - unrecognized_connections
      properties:
        checked_at:
          type: string
          format: date-time
          description: When the provider was asked. Nothing here is cached.
        provider_consulted:
          type: boolean
          description: 'Whether the identity provider was actually read. TRUE for every ordinary answer. It is FALSE only when the comparison could be settled without asking — specifically, when this tenant is not bound to an organization at the provider, which is itself reported in `drift` and which leaves nothing there to read. It is never false because the provider was unreachable: that answer is a 503, because an empty `drift` from a check that did not run would read as "all clear".'
        connection_available_at_sign_in:
          type: boolean
          description: Whether the connection is actually offered on your organization's sign-in screen. A connection can exist at the provider and not be attached to the screen, in which case nobody can use it however correct the rest of its configuration is — which is why this is reported separately from `status` (what you asked for) and from `drift` (what disagrees). ABSENT when the provider was not consulted, and also when it was consulted but does not hold this connection at all — in that case the registration finding in `drift` is the answer.
        drift:
          type: array
          description: 'Every setting that disagrees with what this connection was registered with, or with what a working connection requires. EMPTY MEANS EVERYTHING ASSERTED MATCHED. Order is not significant. Note that nothing in this product will change these values back for you: they are reported for a human to act on, because an automatic "fix" would destroy the evidence that somebody changed them.'
          items:
            $ref: "#/components/schemas/IdpConnectionDrift"
        unrecognized_connections:
          type: array
          description: "Display names of identity-provider connections that exist in your organization but that we have no record of. Usually empty. A name here is worth a look: it is a sign-in route into your organization that we did not register and do not manage. Only the display name is reported — we publish no provider-side identifiers."
          items:
            type: string
    IdpConnectionDrift:
      type: object
      description: "One setting that does not match. Every field is plain English on purpose: these are your identity provider's internal option names in our vocabulary, not its own, so that a report can be read by whoever is holding it."
      required:
        - setting
        - expected
        - actual
        - detail
      properties:
        setting:
          type: string
          description: |-
            Which setting disagrees.

            `automatic_account_linking` — whether a sign-in from your provider is attached to an existing account here purely because the email addresses match. Registered OFF, and switched on it is an account-takeover route: treat a finding here as an incident. `manual_account_linking` — whether somebody may DELIBERATELY link a federated sign-in to an account that already exists here. Registered ON; off means people with existing accounts cannot attach their federated sign-in to them. `account_creation` — whether your provider may create an account for a first-time arrival at all. Registered ON; off makes the connection unable to admit anybody new. `silent_account_creation` — whether that creation skips the provider's own confirmation step. Registered OFF. `profile_overwrite_from_provider` — whether your provider rewrites the name and address we hold on every sign-in. Registered OFF, because an attribute changed upstream would silently rewrite records here, including the address other things match on. `registration` — whether the connection still exists at your provider at all; absent means nobody can sign in through it. `organization` — whether this workspace is still bound to an organization at the provider; this one is ours to fix. `other` — a difference we can detect but cannot yet describe in these terms; it carries a `detail` and a request to contact us, and it should never appear in practice.
          enum:
            - automatic_account_linking
            - manual_account_linking
            - account_creation
            - silent_account_creation
            - profile_overwrite_from_provider
            - registration
            - organization
            - other
        expected:
          type: string
          description: "The value this setting was registered with, in plain terms. One of `on`, `off`, `present`, `absent`, `linked`, or `unexpected` (a value we cannot describe). When `setting` is `other`, this pair is instead `as registered` / `different` — we know something differs and cannot yet name it. Read it as a label, never parse it: the vocabulary may gain words as settings are added."
        actual:
          type: string
          description: The value the provider reports now, in the same terms.
        detail:
          type: string
          description: What the difference means and what it puts at risk, in a sentence or two. This is the field to show a human; `setting` is the field to branch on.
    IdpConnection:
      type: object
      description: |-
        One identity provider a tenant federates sign-in from, as it was registered on the tenant's behalf.
        NOT HERE, DELIBERATELY, AND RECORDED SO THE QUESTION IS NOT REOPENED. There is no client secret and no other credential on this schema, and there is no field anywhere in this API that returns one. The reason is structural rather than a filtering rule we remember to apply: the secret is written to your identity provider at registration time and lives there, so our own records have no column for it and nothing to serialise. Rotating a secret is therefore a change at your provider plus a re-registration on our side — a white-glove request, never a write to this endpoint. Also absent: our provider-side identifier for the registration. It selects nothing, grants nothing, and appears in no request, so publishing it would put an internal identifier on the wire in exchange for no capability — the same call TenantMeAuth records for the identity-provider organization identifier.
      required:
        - id
        - name
        - kind
        - status
        - jit_enabled
        - jit_default_role
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
          description: "Stable identifier for the connection. Useful for correlating with support conversations. No endpoint takes it as a path parameter: a tenant has at most one connection on this surface, so the write (`PATCH /v1/idp-connection`) addresses the tenant's connection without naming it."
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name as the administrator wrote it ("Acme Entra ID"). For display only — never an identifier to match on, and never parsed.
        kind:
          type: string
          description: The federation protocol this connection speaks. `oidc` is what v1 registers; `saml` is published because the field's vocabulary is fixed at the storage layer, NOT as an offer — a SAML connection cannot be set up today.
          enum:
            - oidc
            - saml
        status:
          type: string
          description: |-
            `active` — the connection is available at sign-in. `disabled` — it is not, and nobody can newly sign in through it.
            READ THIS BEFORE TREATING `disabled` AS AN OFF-SWITCH FOR ACCESS. Disabling a connection deprovisions NOBODY: the accounts it asserted still exist, still hold their roles, and any access token already issued keeps working until it expires. It stops the next sign-in, not the current session, and it removes no account. Real deprovisioning — deactivating a user and the bounded window in which that takes effect — is a separate capability that has not shipped yet; until it does, do not build an offboarding process on this field. Deleting a connection is part of that same future work, which is why no DELETE exists here.
          enum:
            - active
            - disabled
        jit_enabled:
          type: boolean
          description: |-
            Whether an unrecognized person at one of this tenant's VERIFIED claimed domains may be admitted automatically on their first federated sign-in, instead of needing an invitation. `false` is the default and the deliberate posture: nothing is provisioned without a recorded prior authorization. Turning it on is an explicit per-tenant decision, made in writing, because it is the least-supervised door into a tenant. Change it with `PATCH /v1/idp-connection`.
            WHAT `true` ADMITS, EXACTLY. Somebody whose account this tenant does not yet hold, who arrives THROUGH this connection, whose address is at a claimed domain that is also `verified: true`. All three conditions, every time: a person who merely holds a password in the same organization is refused even at a verified domain, because a standing consent to admit the people an identity provider asserts is not a consent to admit anybody whose address ends the right way. If `capabilities.domain_admission_available` is false, `true` admits nobody at all in this deployment.
        jit_default_role:
          type: string
          description: "The role an automatically-admitted person receives. `viewer` — the read-only baseline — is the default, and the strong recommendation: it is the least an account can carry, and an administrator can raise it afterwards. `platform` is absent from this list and is not accepted anywhere: it is our internal operator tier, and it can never be reached by an automatic admission."
          enum:
            - admin
            - reviewer
            - viewer
        created_at:
          type: string
          format: date-time
          description: When the connection was registered.
        updated_at:
          type: string
          format: date-time
          description: When any of the fields above last changed. Equal to `created_at` for a connection nobody has adjusted since it was registered.
    IdpClaimedDomain:
      type: object
      description: |-
        An email domain this tenant has claimed, so that an unrecognized address at that domain is routed to the tenant's own sign-in screen — and, where the connection has `jit_enabled: true` and this domain is verified, may be admitted automatically through it.
        A ROW HERE GRANTS NO ACCESS. It routes a login screen and nothing else. Never read one as an authorization record.
      required:
        - id
        - domain
        - verified
        - created_at
      properties:
        id:
          type: string
          format: uuid
        domain:
          type: string
          minLength: 1
          maxLength: 253
          description: "The claimed domain, lower-cased and in its ASCII form. An internationalized domain is stored and reported as its `xn--` form, because that is the single spelling DNS and your identity provider agree on — claim `bücher.example` and you will read back `xn--bcher-kva.example`, which is the same domain, not a different one. Public-suffix domains (`co.uk`, `github.io`) and public mailbox providers (`gmail.com`) cannot be claimed by anyone: the first would capture the sign-in prompt of every organization beneath it, and the second belongs to no single organization."
        verified:
          type: boolean
          description: |-
            Whether control of this domain has been confirmed. TRUE IS WHAT MAKES THE CLAIM DO ANYTHING — an unverified claim routes nothing and admits nobody; it is a reservation waiting on DNS. Verification is a DNS TXT record we ask you to publish. We generate it, hold it and check it — your own identity provider is not involved in proving domain control, only in asserting who your people are.
            It can go back to `false`. If the record is removed, control stops being confirmed, and the claim REMAINS — it is still yours, and still blocks anyone else claiming it — while routing stops working. That is why an unverified row is worth showing rather than hiding: it usually means somebody deleted a DNS record.
        verified_at:
          type: string
          format: date-time
          description: When control was last confirmed. ABSENT — never null — whenever `verified` is false. This is the time we read the provider's verdict, not the moment your DNS changed, so it moves on re-checks.
        created_at:
          type: string
          format: date-time
          description: When the claim was recorded.
    ScenarioStatus:
      type: string
      description: status machine. Derived, materialized transactionally — never set directly by a write.
      enum:
        - created
        - ingesting
        - ready
        - ready_with_warnings
        - ingest_failed
        - evaluating
        - evaluated
    Scenario:
      type: object
      description: The container documents are attached to. Its status is a pure function of its documents' ingestion states, recomputed transactionally on every relevant document-state change. `lifecycle` is the server-derived display view folding that status together with the newest underwriting run and its decision review.
      required:
        - id
        - name
        - status
        - criteria
        - created_at
        - lifecycle
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: The case's human name — required identity, chosen at creation, editable via PATCH. Distinct from `external_ref`, which stays the optional machine reference.
        external_ref:
          type: string
          description: "The tenant's own reference string (\"Reference #\" in the UI); optional, opaque to us."
        question_set_id:
          type: string
          format: uuid
          description: The case's question set — the set underwriting runs against unless a run names another explicitly. Absent when the case has none yet; settable at create and via PATCH.
        vertical:
          type: string
          description: The kind of underwriting this case is bound to. It scopes the case's document-type vocabulary, which question sets it may be run against (only sets of the same kind), and which `declared_type` values its uploads may assert. Absent when the case follows the shipped default. Set when the case is created — explicitly, from the named question set, or inherited from the workspace's own binding — and never changed afterwards.
        program:
          type: string
          description: |-
            Which rulebook FAMILY within this case's vertical governs it. A vertical says what kind of work a case is; a program says which body of rules inside that kind judges it. A case may only be underwritten against a question set whose program matches — a mismatch is a 422 at `POST /v1/scenarios`, `PATCH /v1/scenarios/{id}` and `POST /v1/scenarios/{id}/evaluate`, the same three doors the vertical rule guards. Absent means "no program distinction", which pairs only with sets that also carry none — so a deployment that never mentions programs behaves exactly as it did before they existed.
            Program scopes the case-to-set PAIRING and nothing else: never the document taxonomy, the canonical-key vocabulary, the format adapters, or the committed-format floor, all of which stay vertical-scoped. Set at creation — explicitly, or inherited from the question set the case is created with — and NEVER changed afterwards: the case's documents were ingested under it and its answers produced under it. Moving a case to another program is `POST /v1/scenarios/{scenarioId}/derive`, which creates a new case carrying these documents over.
        derived_from_scenario_id:
          type: string
          format: uuid
          description: "The case this one was derived from — present only on a case created by `POST /v1/scenarios/{scenarioId}/derive`, whose documents and parse artifacts are copies of that source's. Lineage only: from creation onward the two cases are fully independent, each with its own documents, its own runs, and its own erasure. Absent for a case created outright. Deriving from an already-derived case is allowed and CHAINS — each case points at its immediate source, never at the root."
        status:
          $ref: "#/components/schemas/ScenarioStatus"
        lifecycle:
          $ref: "#/components/schemas/ScenarioLifecycle"
        underwriting_readiness:
          $ref: "#/components/schemas/ScenarioUnderwritingReadiness"
          description: "ABSENT means NOT COMPUTED — never \"nothing in the way\": deriving it costs a per-case read the list contract exists to avoid, so the server computes it only for a bounded prefix of the page's DORMANT rows (ingestion finished, never run) and leaves it off every other row. `state: ready` is the positive statement that nothing is in the way."
        criteria:
          type: object
          additionalProperties: true
          description: Routing facts projected from structured facts during ingestion; empty until documents are ingested.
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          description: When the case's run finished.
    ScenarioLifecycleState:
      type: string
      description: "The one display state a list row or header badge renders. Derived server-side, never stored: when the scenario has at least one underwriting run, the newest run's status governs (pending/running -> `evaluating`; paused_budget/paused_degraded -> `on_hold`; failed -> `run_failed`; canceled -> `run_canceled`; completed -> the review fold: no review on the run's newest decision -> `review_pending`, latest review rejected -> `changes_requested`, latest review accepted -> `decided`). With no run yet, it is the ingestion status verbatim (created/ingesting/ready/ready_with_warnings/ingest_failed)."
      enum:
        - created
        - ingesting
        - ingest_failed
        - ready
        - ready_with_warnings
        - evaluating
        - on_hold
        - run_failed
        - run_canceled
        - review_pending
        - changes_requested
        - decided
    ScenarioLifecycle:
      type: object
      description: Server truth for "where is this case in its life?" — replaces the client-side derivation the SPA carried (scenario-decided.ts). Present on both the single get and every list item (computed in the same query — no N+1). A case is DECIDED exactly when its latest run completed AND that run's newest decision's latest review is an acceptance (mandatory human sign-off); a rejection is `changes_requested`, sending the case back for rework.
      required:
        - state
      properties:
        state:
          $ref: "#/components/schemas/ScenarioLifecycleState"
        run_status:
          $ref: "#/components/schemas/EvaluationStatus"
          description: The newest run's status. Absent when the scenario has never been run.
        decision_id:
          type: string
          format: uuid
          description: The reviewed decision (the latest completed run's newest one) — what a report generation pins. Present only when `state` is `decided` or `changes_requested`.
        outcome:
          $ref: "#/components/schemas/DecisionOutcome"
          description: The reviewed decision's outcome. Present with `decision_id`.
        review_action:
          type: string
          enum:
            - accepted
            - rejected
          description: What the human recorded. Present with `decision_id`.
        decided_at:
          type: string
          format: date-time
          description: When that review was recorded. Present with `decision_id`.
    ScenarioUnderwritingReadinessState:
      type: string
      description: |-
        The ONE thing standing between a dormant case and a useful underwriting run, strongest first — a case that needs a question set reports only that, even though its documents are short too.
        `needs_question_set` and `question_set_unpublished` genuinely PREVENT a run (the run pins its case's set's latest published version). `documents_short` does NOT: completeness is advisory by doctrine and never hard-blocks an underwrite — the run is a legitimate choice, it just answers less. Clients must keep that distinction in their wording.
      enum:
        - ready
        - needs_question_set
        - question_set_unpublished
        - documents_short
    ScenarioUnderwritingReadiness:
      type: object
      description: |-
        Why a dormant case has not been underwritten, derived at READ time from the case's own question set's latest published version through the SAME derivation `GET /v1/scenarios/{id}/overview` runs — so a list row and the case page can never disagree, and a document that finishes processing changes the very next read (no cache, no denormalized column, no staleness window).
        Structured, not a sentence: the client owns the wording.
      required:
        - state
      properties:
        state:
          $ref: "#/components/schemas/ScenarioUnderwritingReadinessState"
        required_document_types:
          type: integer
          minimum: 0
          description: How many document types the case's rules require as of now. Present whenever expectations could be derived (i.e. every state except `needs_question_set`/`question_set_unpublished`).
        missing_document_types:
          type: integer
          minimum: 0
          description: How many of `required_document_types` nothing on file carries. Travels with `required_document_types`; `documents_short` is exactly the case where it is greater than zero.
    CreateScenarioRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: The case's human name — required; must contain a non-whitespace character.
        external_ref:
          type: string
          description: 'Optional tenant reference ("Reference #").'
        question_set_id:
          type: string
          format: uuid
          description: Optional question set to attach as the case's own; must name a question set in the caller's tenant (404 otherwise).
        vertical:
          type: string
          description: Optional vertical to bind the case to; must name a vertical present in the tenant's document-type taxonomy (422 otherwise). Given EXPLICITLY alongside a `question_set_id` for a different vertical, the request is a 422 — the two statements contradict each other.
          pattern: ^[a-z][a-z0-9_]*$
        program:
          type: string
          description: |-
            Optional program to bind the case to — which rulebook family inside its vertical judges it. There is deliberately NO tenant-level default program — a tenant's `vertical` binding exists for rows that predate that concept, and a program needs no such legacy door. Given EXPLICITLY alongside a `question_set_id` for a different program, the request is a 422: the two statements contradict each other, exactly as they would for `vertical`.
            Values are your own and there is no catalog to register one in, so the programs a tenant HAS are exactly those its question sets carry: to populate a picker, call `GET /v1/question-sets` and read each set's `program`. What IS enforced is the pairing — a case and the question set it is underwritten against must carry the same program — and immutability: a case's program never changes, so a switch is `POST /v1/scenarios/{scenarioId}/derive`. There is no `program` field on `PATCH /v1/scenarios/{id}` at all, so a `program` sent in a PATCH body is IGNORED, not rejected.
          pattern: ^[a-z][a-z0-9_]*$
    UpdateScenarioRequest:
      type: object
      description: "PATCH semantics — an omitted field is unchanged. `question_set_id` cannot be cleared through this endpoint, only changed: the D3 posture is that a case that has a set keeps having one."
      minProperties: 1
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        question_set_id:
          type: string
          format: uuid
          description: "Must name a question set in the caller's tenant (404 otherwise) written for the SAME vertical as the case (422 otherwise) AND for the same program (422 otherwise). Neither of the case's own axes moves — both were fixed at creation and its documents were validated against them, which is why this PATCH carries no `vertical` and no `program` field. Switching program is `POST /v1/scenarios/{scenarioId}/derive`: a new case, carrying this one's documents over."
    DeriveScenarioRequest:
      type: object
      required:
        - program
      properties:
        program:
          type: string
          description: "The program the NEW case is bound to. Required, and must DIFFER from the source case's (422 otherwise): this endpoint moves a case to another rulebook family, it does not duplicate a case. The empty string is not accepted here — say which program you are moving to. Note the asymmetry: derive cannot target \"no program distinction\", and `POST /v1/scenarios` carries no documents — so programmed → unprogrammed is a ONE-WAY door that costs a re-upload. Bind a case to a program only when you mean it."
          pattern: ^[a-z][a-z0-9_]*$
        question_set_id:
          type: string
          format: uuid
          description: "Optionally bind the new case's own question set in the same call — usually the point of the switch, since the new program's rulebook is a different set. Must name a set in the caller's tenant (404 otherwise) written for the new case's vertical AND its new program (422 otherwise): derive is not a laxer third door into a pairing. Omitted, the new case starts with no question set and is bound later through `PATCH /v1/scenarios/{scenarioId}`."
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: "Optional name for the new case. Omitted, the source case's name is reused: a program switch is the same underwriting subject seen under a different rulebook. Naming both cases distinctly is usually kinder to whoever reads the list afterwards."
        external_ref:
          type: string
          description: Optional tenant reference for the new case — same field, same (absent) constraints as `POST /v1/scenarios`, so a reference this API accepted once is always re-statable. Omitted, the source's `external_ref` is INHERITED — honest for a program switch, but worth overriding when that reference is the identifier your own system keys on, because two of our cases would then answer to one of yours. Send `""` to give the derived case no reference at all.
        routing_rule_index:
          type: integer
          minimum: 0
          description: |-
            Optional: the `rule_index` from `GET /v1/scenarios/{scenarioId}/routing-proposal`, stating that this derive is a person ACCEPTING that proposal (rules propose, a human confirms). Omit it for a switch the person decided on their own; the derive behaves identically either way.

            It is VERIFIED, not just recorded: the proposal is recomputed from this case's promoted facts, and the request is a 422 unless a rule still matches, that rule is the one at this index, and the program it proposes is the `program` you asked to derive to. So a stale proposal — the rules were edited, or the evidence changed after the person read it — is refused rather than logged as an acceptance of something that is no longer true.

            WHAT "VERIFIED" MEANS, EXACTLY, because the difference matters: the proposal is recomputed from the rules AS THEY STAND AT DERIVE TIME. That refuses every staleness a person would recognise, but an in-place edit of the SAME rule that leaves it still matching, still at this index and still naming this program passes — and the event then records the rule as it stood at the derive, not as the person read it. `expected_rules_digest` below closes that.

            When it verifies, the `scenario.derived` audit event carries the routing evidence: the rule index, the digest of the rule set in force, the facts the rule matched on, and whether the caller pinned the digest (`digest_asserted`). That is what makes routing auditable end to end — a program on a case can be traced back to the rule as it stood at the derive, pinned to what the person actually saw only when `digest_asserted` is true.
          example: 0
        expected_rules_digest:
          type: string
          description: |-
            Optional, and meaningful only alongside `routing_rule_index`: send the `rules_digest` from the proposal you showed the person. A mismatch means the rules changed underneath them, and the derive is refused `422` with `reason: routing_proposal_stale` rather than recording an acceptance of something the person never saw.

            THE GAP IT CLOSES. Index, rule and program are verified against the rules in force at derive time, which catches the three stalenesses a person would notice. It does not catch an IN-PLACE EDIT that leaves everything it checks unchanged: rule 0 `property.usage eq primary_residence`, edited to `property.usage exists`, still matches, still sits at index 0 and still names the same program — so the derive is accepted and the audit trail attributes to that reviewer a rule they never read. Pinning the digest they were shown makes that substitution a refusal.

            Recorded either way. The `scenario.derived` event's routing block carries `digest_asserted`, so the chain itself says whether the acceptance was pinned to the rule set the person saw or merely verified against the one then in force.

            An opaque token to compare for equality — never parse or recompute it. Any value that is not the digest in force is refused, including `""` (what a case with no rules configured reports, which is already refused for having no proposal at all). Sending it WITHOUT `routing_rule_index` is a plain `422`: there is no cited proposal for it to pin.
          example: 262d6b6d1c0e4f8a9b7c5d3e1f0a2b4c6d8e0f1a2b3c4d5e6f708192a3b4c5d6
    RoutingProposal:
      type: object
      description: What the tenant's program selection rules propose for one case, recomputed on demand and persisted nowhere.
      required:
        - matched
        - rule_count
        - facts_considered
        - cited_facts
      properties:
        matched:
          type: boolean
          description: Whether any rule fired. `false` is the ordinary, first-class answer — read `rule_count` to tell "no rules are configured" from "rules ran and none matched" — and it means "choose manually, exactly as today", never "use the default program" (there is no default program).
        program:
          type: string
          description: |-
            The proposed program. Present only when `matched`. It is a PROPOSAL: nothing has been bound, and applying it is a human act at create or derive.

            IT MAY ALREADY BE THIS CASE'S OWN PROGRAM, and then the proposal is already satisfied: there is nothing to derive, and following it into `POST /v1/scenarios/{scenarioId}/derive` earns a 422 (derive is the program-SWITCH door and refuses a same-program request). Compare it to the case's `program` before offering the accept action and present the proposal as agreeing with where the case already is.
          example: some_program
        rule_index:
          type: integer
          minimum: 0
          description: The 0-based position of the matched rule in the tenant's ordered list. Present only when `matched`. This is the stable handle to pass back as `routing_rule_index` when deriving, and the value the resulting audit event records — which is what makes first-match ordering part of the contract rather than an implementation detail.
        rules_digest:
          type: string
          description: |-
            A digest of the exact rule set this proposal was computed under: the SHA-256 of the stored `routing` document, so REORDERING the same rules changes it — two orderings are two different rule sets, because order is the semantics. Absent when no rules are configured.

            EQUALITY IS THE PROPERTY YOU MAY RELY ON, and it holds both ways: an identical rule set always produces an identical digest (the document is hashed in one canonical, declaration-ordered shape, so re-saving the same rules hashes the same bytes), and any change to it — an edited literal, an added condition, a reordering — produces a different one. That is what makes comparing two digests meaningful. Treat it as an opaque token to compare for equality, never one to parse or to recompute client-side.

            SEND IT BACK when a person accepts this proposal: `POST /v1/scenarios/{scenarioId}/derive` takes it as `expected_rules_digest`, which turns that comparison into a REFUSAL at the moment of acceptance rather than something an auditor discovers afterwards.
        rule_count:
          type: integer
          minimum: 0
          description: 'How many rules were evaluated. `0` means none are configured — the shipped default, in which the UI shows no routing affordance. A non-zero count with `matched: false` means the rules RAN and did not decide. That is all this field separates: "no rules configured" from "rules ran". WHY a run did not decide is read from `facts_considered`.'
        facts_considered:
          type: integer
          minimum: 0
          description: "How many PROMOTED facts the engine was given for this case — the other half of an unmatched answer. `0` with a non-zero `rule_count` means no rule COULD have matched, because this case has promoted nothing yet (promotion to canonical keys is optional configuration); tell the person that rather than \"the rules disagreed\". A non-zero value with `matched: false` means the rules genuinely ran against this case's evidence and none of them fit. `0` when `rule_count` is `0` too — nothing is evaluated there, and the fact read is skipped. Facts belonging to an ERASED document are not among them: they never route and are never cited (see the operation description)."
          example: 12
        cited_facts:
          type: array
          description: The PROMOTED facts that satisfied the matched rule's conditions, one per condition, in condition order — the proposal's grounding, and what a reviewer is actually shown. Empty when nothing matched. Each entry names the fact row, its document and its source path, so the evidence is reachable and not merely asserted.
          items:
            $ref: "#/components/schemas/RoutingCitedFact"
    RoutingCitedFact:
      type: object
      description: One promoted fact a matched rule compared, with enough provenance to open the evidence it came from.
      required:
        - fact_id
        - canonical_key
        - value
        - document_id
      properties:
        fact_id:
          type: string
          format: uuid
          description: "The structured-fact row this value was read from — a POINT-IN-TIME handle, not a durable name for the reading: a re-parse mints new fact rows (parse artifacts are immutable), so a `fact_id` recorded on an older `scenario.derived` audit event may no longer resolve. Follow `document_id` + `canonical_key` + `source_path` when you need provenance that survives a re-parse."
        canonical_key:
          type: string
          description: The canonical key the rule's condition named.
          example: some.canonical.key
        display_name:
          type: string
          description: The human label for `canonical_key`, from the vocabulary of the case's own vertical. Absent when the vocabulary does not carry the key — a tenant's own promotion-mapping rules may promote keys the shipped pack does not — in which case render a principled fallback rather than the dotted machine key.
          example: Some Canonical Key
        value:
          type: string
          description: The fact's value as the rule compared it. Every value is normalized to a string form first.
        entity_ref:
          type: string
          description: Which entity the fact belongs to, in the `entity:ordinal(/sub_entity:ordinal)*` grammar — load-bearing when a key is promoted once per party or per account and the reviewer needs to know which one the rule matched on.
        document_id:
          type: string
          format: uuid
          description: The document the fact was parsed from.
        source_path:
          type: string
          description: The raw path inside that document the value was read from — the provenance a reviewer follows to the source. A machine identifier, so pair it with `display_name` rather than showing it as a label.
    ScenarioList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Scenario"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    ScenarioOverview:
      type: object
      description: "GET /v1/scenarios/{id}/overview's composed shape: the one workflow-shaped read the case-detail surface renders from. Every member is computed in a single tenant-scoped read path."
      required:
        - scenario
        - documents
        - attention
      properties:
        scenario:
          $ref: "#/components/schemas/Scenario"
        documents:
          $ref: "#/components/schemas/ScenarioOverviewDocuments"
        latest_run:
          $ref: "#/components/schemas/ScenarioOverviewRun"
          description: The newest underwriting run. Absent when the case has never been run.
        attention:
          type: array
          items:
            $ref: "#/components/schemas/ScenarioAttentionItem"
          description: The "needs you" list powering the header action slot, in fixed priority order (decision review first, then failed documents, then pending rule changes). Empty when nothing needs a human.
    ScenarioOverviewDocuments:
      type: object
      description: "The case's document inventory, merged with what its questions expect. Computed as of now from the case's own question set. Advisory: it never blocks a run."
      required:
        - extra
        - counts
      properties:
        question_set_id:
          type: string
          format: uuid
          description: The expectation basis — the case's own question set. Absent when the case has none.
        question_set_version_id:
          type: string
          format: uuid
          description: That set's latest published version the expectations derived from. Absent with `expected`.
        expected:
          type: array
          items:
            $ref: "#/components/schemas/ScenarioOverviewExpectedDocument"
          description: One entry per expected document type, at every derivation state (required | conditional | not_required — the client groups them; a "ghost row" is exactly an entry with present=false). ABSENT (not empty) when no expectation basis exists — the case has no question set, or its set has no published version yet.
        extra:
          type: array
          items:
            $ref: "#/components/schemas/ScenarioOverviewDocument"
          description: "Every inventory document no expectation claims: classified evidence of an unclaimed type, unclassified segments, directive (rule-change) documents (marked `is_directive`), and fact-only structured evidence files of an unclaimed type. With no expectation basis, this is the whole inventory."
        counts:
          $ref: "#/components/schemas/ScenarioOverviewDocumentCounts"
    ScenarioOverviewExpectedDocument:
      type: object
      description: One expected document type merged with the evidence documents that satisfy it.
      required:
        - doc_type
        - state
        - present
        - documents
      properties:
        doc_type:
          type: string
          description: Taxonomy key.
        display_name:
          type: string
          description: The taxonomy's display name, resolved at read time; absent when the type is not in the tenant's taxonomy.
        state:
          type: string
          enum:
            - required
            - conditional
            - not_required
          description: The gate-aware derivation state — the "how much does this matter" signal a missing (ghost) row renders with.
        present:
          type: boolean
        documents:
          type: array
          items:
            $ref: "#/components/schemas/ScenarioOverviewDocument"
          description: The evidence documents carrying this type. Empty exactly when present=false (the ghost row).
        directive_present_count:
          type: integer
          description: 'Absent when none do. A directive document is a rule source and never evidence, so these are excluded from `present`/`documents` exactly as before; the count exists so a ghost row with `present: false` and a non-zero count here can say "already on file as a directive" instead of offering an upload that cannot satisfy it. The question asking for a directive type is the defect — the publish-time authoring lint reports it as `directive_evidence_doc_type`.'
        directive_review_pending_count:
          type: integer
          description: The subset of `directive_present_count` whose classification is not yet auto/confirmed, and which is therefore not acting as a rule source yet either. Absent at zero.
    ScenarioOverviewDocument:
      type: object
      description: One evidence (or directive) document row of the overview merge — a logical document joined to its physical document's filename and parse status, or a fact-only structured evidence file (which produces no logical document and so carries no logical_document_id).
      required:
        - document_id
        - filename
        - parse_status
      properties:
        logical_document_id:
          type: string
          format: uuid
          description: Absent for a fact-only structured evidence document.
        document_id:
          type: string
          format: uuid
        filename:
          type: string
        parse_status:
          type: string
          enum:
            - pending
            - parsing
            - parsed
            - degraded_parse
            - failed
            - unsupported_pending
        doc_type:
          type: string
          description: The row's classified (or, for a fact-only file, recorded) type. Absent when unclassified.
        display_name:
          type: string
          description: The taxonomy's display name for doc_type, resolved at read time.
        is_directive:
          type: boolean
          description: Present (true) only on a directive (rule-change) document row in `extra`.
    ScenarioOverviewDocumentCounts:
      type: object
      description: The header-band counters of the documents merge.
      required:
        - present
        - missing
        - failed
      properties:
        present:
          type: integer
          description: Expected document types with at least one evidence document (any state). 0 when no expectation basis exists.
        missing:
          type: integer
          description: Expected REQUIRED document types with none (state == required && !present — the doctrine's derivable "missing"). 0 when no expectation basis exists.
        failed:
          type: integer
          description: The scenario's documents that failed — upload verification (status=failed) or parsing (parse_status=failed).
    ScenarioOverviewRun:
      type: object
      description: The newest underwriting run, shaped for the overview header — the run row plus its pinned set's display identity.
      required:
        - id
        - status
        - question_set_id
        - question_set_version_id
        - question_set_name
        - question_set_version
        - created_at
      properties:
        id:
          type: string
          format: uuid
        status:
          $ref: "#/components/schemas/EvaluationStatus"
        question_set_id:
          type: string
          format: uuid
        question_set_version_id:
          type: string
          format: uuid
        question_set_name:
          type: string
          description: The pinned set's human name — what a run row renders ("Income Assessment v3"), never a UUID.
        question_set_version:
          type: integer
          description: The pinned version's number (the "v3").
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        progress:
          $ref: "#/components/schemas/RunProgress"
          description: Staged progress, present only while the run is in flight. A settled run's progress is history rather than overview material, so it is omitted once the run finishes.
        decision:
          $ref: "#/components/schemas/ScenarioOverviewDecision"
          description: The run's newest decision, if any.
    ScenarioOverviewDecision:
      type: object
      description: The decision summary the overview header renders — id/outcome/review state, not the full rationale (GET.../decisions carries that).
      required:
        - id
        - outcome
        - review_state
      properties:
        id:
          type: string
          format: uuid
        outcome:
          $ref: "#/components/schemas/DecisionOutcome"
        review_state:
          $ref: "#/components/schemas/DecisionReviewState"
        answers_evaluated:
          type: integer
          minimum: 0
          description: The same count `Decision.answers_evaluated` carries, read off the same stored snapshot — so a surface that renders this summary can qualify an outcome computed over nothing instead of presenting a bare "Approve". Omitted (never 0) when the stored membership is unreadable, keeping "evaluated nothing" and "not known" distinct exactly as the full decision resource does.
    ScenarioAttentionItem:
      type: object
      description: "One \"needs you\" item for this case. `kind` is deliberately a plain string, not a wire enum (the AnswerFlag.kind precedent): the vocabulary today is decision_review_pending (a completed run's decision awaits the mandatory human sign-off — decision_id + evaluation_id present) | documents_failed (count + document_ids present) | rule_changes_pending (count + supersession_ids present) | directive_statements_flagged (count + directive_statement_flag_ids present), and a client meeting an unrecognized kind must render it (humanized) rather than drop the one signal telling a human to look."
      required:
        - kind
        - count
      properties:
        kind:
          type: string
        count:
          type: integer
          minimum: 1
          description: How many things this item stands for (1 for decision_review_pending) — counted at the grain a HUMAN would count them on screen, which is not always the grain of the ids beside it. For directive_statements_flagged that is one per STATEMENT; see `directive_statement_flag_ids`.
        decision_id:
          type: string
          format: uuid
          description: The decision awaiting review (kind=decision_review_pending).
        evaluation_id:
          type: string
          format: uuid
          description: The run that produced it (kind=decision_review_pending).
        document_ids:
          type: array
          items:
            type: string
            format: uuid
          description: The failed documents (kind=documents_failed).
        supersession_ids:
          type: array
          items:
            type: string
            format: uuid
          description: The pending rule changes (kind=rule_changes_pending).
        directive_statement_flag_ids:
          type: array
          items:
            type: string
            format: uuid
          description: |-
            The still-open flagged directive statements (kind=directive_statements_flagged): rule-change requests the automated matcher could not resolve, on the LATEST extraction of each directive document, that no supersession covers and no human has dismissed. Sibling of rule_changes_pending and deliberately a SEPARATE kind: a pending supersession asks "confirm or reject this rule change", while a flagged statement asks "read this and decide whether it is one at all" — two different actions on two different surfaces. COUNT AND ARRAY ARE AT DIFFERENT GRAINS, deliberately. `count` is one per STATEMENT: a flagged statement is what a human decides about and what the review queue draws as one row, and a structured document routinely quotes one statement twice — from a container element and from the line nested inside it — so counting the cited blocks made this line say "2" over a queue holding one row. This array stays at the flag-ROW grain, because every action (POST /directive-statement-flags/{flagId}/dismissal.../rematch) addresses ONE row and a client that means to act needs all of them.

            THE STATEMENT IDENTITY, stated so a client grouping the review listing gets the same number this count reports: two flag rows belong to the same statement when they carry the same `statement_ref` within the same extraction run (GET /scenarios/{scenarioId}/directives lists a run's flags under one document, and `statement_ref` is the match stage's key WITHIN one run over one document, so the run scopes it). A flagged block with no flag row at all belongs to no statement and is its own entry — two of them never merge.

            A PARTIALLY DISMISSED STATEMENT IS STILL COUNTED: it drops out only when every one of its flag rows has been dismissed. One passage judged not-a-rule-change says nothing about the passage still on the queue, and the queue still shows the statement.
    DocumentTypeStatus:
      type: string
      description: "A forward-only status: active becomes deprecated, never the other way round."
      enum:
        - active
        - deprecated
    DocumentType:
      type: object
      description: A tenant's document-type taxonomy entry. `key` is IMMUTABLE after creation — published question versions and classification reference it, so a rename (display_name edit) can never re-route anything. "Delete" is deprecation (`status` + optional `replaced_by_id`), never a hard delete.
      required:
        - id
        - key
        - display_name
        - is_directive
        - status
        - created_at
      properties:
        id:
          type: string
          format: uuid
        key:
          type: string
          description: snake_case machine value; immutable after creation.
        display_name:
          type: string
        description:
          type: string
        vertical:
          type: string
          description: The vertical this entry belongs to (binding, per-row since). Absent for the shipped default vertical's entries and for rows that predate the binding.
        is_directive:
          type: boolean
          description: Directive class marker (automated underwriting output, insurance certificates, condition sheets).
        status:
          $ref: "#/components/schemas/DocumentTypeStatus"
        replaced_by_id:
          type: string
          format: uuid
          description: Set at deprecation only; the successor taxonomy entry, if any.
        created_at:
          type: string
          format: date-time
    CreateDocumentTypeRequest:
      type: object
      required:
        - key
        - display_name
      properties:
        key:
          type: string
          description: snake_case machine value; immutable after creation.
          pattern: ^[a-z][a-z0-9_]*$
        display_name:
          type: string
        description:
          type: string
        vertical:
          type: string
          description: Optional vertical this entry belongs to (snake_case). Omitted means the shipped default vertical — the entry appears in every unbound case's picker.
          pattern: ^[a-z][a-z0-9_]*$
        is_directive:
          type: boolean
          default: false
    UpdateDocumentTypeRequest:
      type: object
      description: display_name/description/is_directive only — key and status are untouched.
      required:
        - display_name
      properties:
        display_name:
          type: string
        description:
          type: string
        is_directive:
          type: boolean
          default: false
    DeprecateDocumentTypeRequest:
      type: object
      properties:
        replaced_by_id:
          type: string
          format: uuid
    DocumentTypeList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/DocumentType"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    ParseErrorDetail:
      type: object
      description: One field-level violation (or, at info severity, a merely-informational note) within a typed parse_error.
      required:
        - path
        - message
      properties:
        path:
          type: string
          description: Source-document path the violation was found at (e.g. an XPath-like locator).
        message:
          type: string
          description: Human-readable, uploader-safe description of the violation.
        severity:
          type: string
          enum:
            - error
            - info
          description: Defaults to `error` when absent (every pre-existing producer). `info` marks a field whose raw value fell outside a mapped enum's known vocabulary — the fact still ingested verbatim, never a reason to fail or degrade the field.
    ParseError:
      type: object
      description: A typed, user-facing parse outcome. `message` is written for uploaders, never engineers, and never leaks internals — safe to render as-is. `detail` is the structured-adapter field-level report; empty/absent for layout-family failures.
      required:
        - code
        - message
      properties:
        code:
          type: string
          enum:
            - format_undetected
            - unsupported_version
            - invalid_structure
            - parse_failed
            - degraded_parse
            - too_large
            - informational
          description: "`informational` is not a failure: it appears only when a cleanly-`parsed` document's structured parse produced info-severity `detail` entries (an enum value stored verbatim outside its mapped vocabulary). The UI renders it with neutral/info tone, distinct from every other (failure) code."
        message:
          type: string
        detail:
          type: array
          items:
            $ref: "#/components/schemas/ParseErrorDetail"
    Document:
      type: object
      description: A document uploaded under a scenario. `status` is the upload lifecycle (pending -> uploaded | failed); `parse_status` is the orthogonal ingestion lifecycle the DocumentIngestion workflow drives — `pending` here until that workflow lands.
      required:
        - id
        - scenario_id
        - filename
        - content_type
        - status
        - parse_status
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        filename:
          type: string
        content_type:
          type: string
        size_bytes:
          type: integer
          format: int64
          description: Actual uploaded size; present once status is `uploaded`.
        sha256:
          type: string
          description: Lowercase hex sha256 of the uploaded bytes; present once status is `uploaded`.
        status:
          type: string
          enum:
            - pending
            - uploaded
            - failed
        failure_reason:
          type: string
          description: 'Present only when `status` is `failed`, absent otherwise. It is operator text, not customer copy: a client renders a human sentence for the failure family and keeps this behind its "Technical details" disclosure. Without it a failed upload is a dead row — `parse_status` never left `pending` (a document that never finalized never enters the parse lifecycle), so no field on this resource said what went wrong.'
        parse_status:
          type: string
          enum:
            - pending
            - parsing
            - parsed
            - degraded_parse
            - failed
            - unsupported_pending
        parse_error:
          $ref: "#/components/schemas/ParseError"
          description: "Present when parse_status is `failed` or `degraded_parse`; also present, with `code: informational`, on an otherwise cleanly-`parsed` document that has info-severity field-level notes only; absent otherwise."
        parse_started_at:
          type: string
          format: date-time
          description: "It is an elapsed CLOCK, not a percentage and not an ETA: the layout parse is one blocking call into the parser with no sub-progress of its own, so a client computes its own pace from this the way it already does from `DirectiveExtractionProgress.started_at`. ABSENT is a normal shape — a document still `pending`, one that never finalized, or a row whose parse ran before this field existed — and a client renders the plain status then rather than treating it as an error. Never reset: a reparse is a new document (parse immutability)."
        parser_version:
          type: string
          description: The version of the reader that produced this document's parse. Present once parsed.
        page_count:
          type: integer
          description: "Layout family only. Present once the PARSER has rendered the document's pages — which is BEFORE `parse_status` goes terminal: segmentation and classification run after the layout parse and are the slow, model-driven stages, so a document commonly sits in `parsing` for minutes already carrying its real page count. That is deliberate — it is the one honest progress signal this lifecycle has — so do NOT read a present `page_count` as \"parsing finished\"; `parse_status` is the only thing that says that. Absent for the structured family (no pages) and until the layout parse commits."
        declared_type:
          type: string
          description: Optional uploader-asserted document type.
        declared_description:
          type: string
        declared_party:
          type: string
        classified_type:
          type: string
          description: |-
            The type the pipeline determined from the file's own content, for a
            structured evidence document that produces no logical document
            (a fact-only file). Absent for the
            layout family, whose classification lives on its logical documents,
            and absent until the classification stage has run. Counted into the
            completeness present set in preference to `declared_type`.
        supersedes_document_id:
          type: string
          format: uuid
          description: The predecessor document this upload revises, when one was declared at create time. For a directive document it is what carries lineage, the re-derive diff and the re-evaluation offer across a revision — a revision is always a NEW document row, because a parse is immutable once it exists.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        erased_at:
          type: string
          format: date-time
          description: "When this document's content was erased (per-record deletion; DPA). Present only on an erased document: every content-bearing column (filename, declared metadata, parsed text, extracted values, quoted evidence) has been overwritten with an explicit tombstone marker and the underlying blob bytes destroyed; ids, geometry, and citations still resolve — to an explicit \"evidence erased\" state, never a dangle. An erased document can never be re-parsed (the bytes are gone; reparse answers 409 `document_erased`)."
    DocumentErasureRequest:
      type: object
      required:
        - reason
      properties:
        reason:
          type: string
          minLength: 1
          description: Mandatory recorded justification (e.g. the DSR/contract request reference). Carried on the audit trail; an irreversible act needs a recorded why.
    DocumentErasure:
      type: object
      description: Acknowledgement that a document-content erasure was accepted and its workflow started. Ids and flags only — the erasure's full per-table accounting lands on the `document.erased` audit event, and `Document.erased_at` is the queryable completion anchor.
      required:
        - document_id
        - scenario_id
        - already_erased
        - requested_at
      properties:
        document_id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
          description: "The scenario this document belongs to — the same reason the preview carries it: this route is addressed by the document id alone, and the only document read (`GET /v1/scenarios/{scenarioId}/documents/{documentId}`) is nested under the scenario, so without this a caller cannot poll `Document.erased_at` (the completion anchor this 202 points at)."
        already_erased:
          type: boolean
          description: True when the document already carried an erasure anchor at request time; the run then re-verifies/completes the earlier erasure (idempotent by design) rather than starting a new one.
        requested_at:
          type: string
          format: date-time
    DocumentErasurePreview:
      type: object
      description: The dry-run blast radius of a document-content erasure — what erasing THIS document would destroy, flag and retain, so a person consents to consequences rather than to an id. Counts and the document's own handle only; computed read-only, nothing written.
      required:
        - document_id
        - scenario_id
        - filename
        - parse_status
        - already_erased
        - erasable
        - answers_to_flag
        - decisions_to_offer_reevaluation
        - cache_entries_to_delete
        - cache_entries_retained_shared
        - blob_objects
        - related_copies
      example:
        document_id: 0193f2c4-1e00-7000-8000-0000000000aa
        scenario_id: 33333333-3333-4333-8333-333333333333
        filename: statement-q3.pdf
        parse_status: parsed
        already_erased: false
        erasable: true
        answers_to_flag: 3
        decisions_to_offer_reevaluation: 1
        cache_entries_to_delete: 2
        cache_entries_retained_shared: 1
        blob_objects: 9
        related_copies:
          - document_id: 0193f2c4-1e00-7000-8000-0000000000bb
            scenario_id: 44444444-4444-4444-8444-444444444444
            scenario_name: Same subject, other program
            filename: statement-q3.pdf
            relation: derived_into
            erased: false
      properties:
        document_id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
          description: The scenario this document belongs to. Carried because the only document read (`GET /v1/scenarios/{scenarioId}/documents/{documentId}`) is NESTED under the scenario while the erasure pair is addressed by the document's globally unique id alone — so a caller holding only that id would have no way back to the resource it just previewed.
        filename:
          type: string
          description: |-
            Shown to the human deciding, so they can see they are about to
            erase the file they meant. It is about to be tombstoned, and this
            read-only call persists it nowhere.

            **When `already_erased` is true this is already the marker
            `[erased]`, not a name.** Never present it to a person as one, and
            never use it as a type-to-confirm target: every erased record
            carries the identical string, so a confirmation gate keyed on it
            accepts the same input for all of them and checks nothing.
        parse_status:
          type: string
          enum:
            - pending
            - uploaded
            - parsing
            - parsed
            - failed
            - unsupported_pending
          description: The document's current parse state — the reason an erasure can be refused. Same closed set as `Document.parse_status`, whose members differ from the guideline lifecycle's (`failed` here, not `parse_failed`).
        already_erased:
          type: boolean
          description: True when this document's content is already destroyed. A repeat erasure is legal and idempotent (it finishes or re-verifies the earlier pass) — it is not a second destruction.
        erased_at:
          type: string
          format: date-time
          description: Present only when `already_erased` — when the content was FIRST destroyed. It never moves.
        erasable:
          type: boolean
          description: "Whether an erasure would proceed right now. False only while the document's own parse is mid-pipeline: the parse is concurrently writing the very rows the erasure would tombstone, so the erasure waits for a terminal parse state (409 `parse_in_flight`)."
        answers_to_flag:
          type: integer
          format: int64
          description: How many existing answers cite this document and would be flagged `evidence_erased` — flagged for a human to re-evaluate, never changed, and never auto-acceptable afterwards.
        decisions_to_offer_reevaluation:
          type: integer
          format: int64
          description: How many current decision proposals rest on those answers and would receive an explicit re-evaluation offer. Zero when nothing is flagged.
        cache_entries_to_delete:
          type: integer
          format: int64
          description: Cache entries whose every recorded lookup was this document's, so both the index row and the stored artifact go. Entries shared with another document are kept.
        cache_entries_retained_shared:
          type: integer
          format: int64
          description: Cache entries this document shares with another recorded lookup, which therefore STAY. A shared entry is byte-identical content another live party also supplied and still holds in their own rows, so deleting it would degrade that consumer without shrinking the residue. Disclosed as a count so the residue is sized rather than discovered later.
        blob_objects:
          type: integer
          format: int64
          description: Stored objects to be deleted — the original bytes, the parsed JSON, the page renders, and any exclusive cache artifacts.
        related_copies:
          type: array
          description: |-
            OTHER documents in this workspace holding this document's exact content, which THIS erasure does not touch. **Erasing this document does NOT erase these — each is its own erasure.** An operator completing a deletion request must erase every copy listed here, or the content survives under another id.

            Why the list exists. The consequence is that one deletion REQUEST is now N erasures, and this array is what makes that visible before the first one rather than after the last.

            Matched on content (`sha256`), not on lineage, so a document somebody uploaded twice is listed too — it is equally un-erased by this call. `relation` then says how each one got there. Empty for a document that has no copy and for one whose upload never finalized (no content hash yet, so nothing to compare).
          items:
            $ref: "#/components/schemas/RelatedDocumentCopy"
    RelatedDocumentCopy:
      type: object
      description: One other document holding byte-identical content to the one being previewed — a separate record, with its own erasure.
      required:
        - document_id
        - scenario_id
        - scenario_name
        - filename
        - relation
        - erased
      properties:
        document_id:
          type: string
          format: uuid
          description: "The id to send to `POST /v1/documents/{documentId}/erase` to erase THIS copy. Preview it first, exactly as you did this one: its own blast radius is its own."
        scenario_id:
          type: string
          format: uuid
          description: The case the copy belongs to. Every document belongs to a case.
        scenario_name:
          type: string
          description: "The case's name, so the person reading the list can recognise it without a second read. Always present: a case's name is required and cannot be cleared."
        filename:
          type: string
          description: "The copy's own filename. Same caveat as the preview's own `filename`: when `erased` is true this is already the marker `[erased]` rather than a name, so never present it as one."
        relation:
          type: string
          enum:
            - derived_from
            - derived_into
            - same_content
          description: |-
            How this copy came to hold the same content.

            `derived_from` — it lives in the case THIS case was derived from, so this document is the copy and that one is the original upload.

            `derived_into` — it lives in a case derived FROM this one, so it is the copy this document produced.

            `same_content` — same bytes, no derive lineage between the two cases. Usually the same file uploaded twice; possibly a longer lineage chain, since only the immediate derive edge is labelled. The erasure consequence is identical either way, which is why this is one closed set and not two separate lists.
        erased:
          type: boolean
          description: True when this copy's content is ALREADY destroyed. It stays in the list rather than dropping out of it — an operator working through a deletion request needs the whole set, with the ones already handled visibly handled.
        erased_at:
          type: string
          format: date-time
          description: Present only when `erased` — when that copy's content was FIRST destroyed.
    DocumentList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Document"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    CreateDocumentRequest:
      type: object
      required:
        - filename
        - content_type
        - size_bytes
      properties:
        filename:
          type: string
        content_type:
          type: string
        size_bytes:
          type: integer
          format: int64
          description: Declared size in bytes; re-verified against the actual upload at finalize.
        declared_type:
          type: string
          description: "The uploader's assertion of what this file IS — a document-type taxonomy key. Must be an ACTIVE key (422 for unknown or deprecated) belonging to THIS CASE's vertical (422 otherwise): a rulebook's vocabulary never attaches to a case written under a different one. Independent of the FILE-FORMAT gate: every case in every vertical accepts all eight committed format families, and 415 still means only \"no registry entry for this content type\"."
        declared_description:
          type: string
        declared_party:
          type: string
        supersedes_document_id:
          type: string
          format: uuid
          description: |-
            Optional: the document in THIS scenario that this upload revises.
            Must resolve inside the same scenario (422 otherwise). Re-uploading a
            revised directive document is necessarily a new document row
            (parse-immutability), and this link is what makes the
            re-derive diff, lineage carry and re-evaluation offer fire for it —
            the same continuity a reclassification override already gets. When
            omitted, a directive re-upload is still linked automatically if
            exactly one prior directive document in the scenario has the same
            declared/classified type AND the same filename; anything ambiguous
            is left unlinked rather than guessed.
    CreateDocumentResponse:
      type: object
      required:
        - document
        - upload_url
        - upload_expires_at
      properties:
        document:
          $ref: "#/components/schemas/Document"
        upload_url:
          type: string
          description: Presigned PUT URL. The client must PUT with the same Content-Type declared above.
        upload_expires_at:
          type: string
          format: date-time
    CompleteDocumentResponse:
      type: object
      required:
        - document
      properties:
        document:
          $ref: "#/components/schemas/Document"
        duplicate_of_document_id:
          type: string
          format: uuid
          description: Set when another document in this tenant already has identical content (sha256 dedupe). Informational only — both documents remain distinct rows.
    GetDocumentDownloadResponse:
      type: object
      required:
        - document
        - download_url
        - download_expires_at
      properties:
        document:
          $ref: "#/components/schemas/Document"
        download_url:
          type: string
          description: Presigned GET URL.
        download_expires_at:
          type: string
          format: date-time
    DocumentPage:
      type: object
      description: A rendered scenario-document page.
      required:
        - page_no
        - render_url
        - width
        - height
        - expires_at
      properties:
        page_no:
          type: integer
        render_url:
          type: string
          description: Presigned GET URL for the page's PNG render.
        width:
          type: integer
          description: Rendered PNG pixel width.
        height:
          type: integer
          description: Rendered PNG pixel height.
        expires_at:
          type: string
          format: date-time
    DocumentPageList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/DocumentPage"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    LogicalDocument:
      type: object
      description: "A segment of a scenario document, with its classification result. `doc_type`/`classified_by` are absent when nobody has classified it yet (manual mode with no uploader assertion, or a total segmentation/classification failure — both land `review_status: needs_review`). `detected_*` are the shadow-detection columns, present only when an uploader assertion was effective and shadow detection ran."
      required:
        - id
        - scenario_id
        - document_id
        - page_start
        - page_end
        - is_directive
        - review_status
        - classification_flags
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        document_id:
          type: string
          format: uuid
        page_start:
          type: integer
        page_end:
          type: integer
        doc_type:
          type: string
          description: Taxonomy key (tenant config); absent when unclassified.
        party:
          type: string
        period:
          type: string
        classified_by:
          type: string
          enum:
            - model
            - uploader
            - human
            - adapter
          description: Which MECHANISM produced the effective classification; absent when unclassified. `adapter` means the parsing adapter PROVED the class from the file's own structure — deterministic, reproducible, no model involved — so `classification_confidence` is 1.0 and there is nothing probabilistic for a reviewer to second-guess (they may still override it, which lands `human`).
        classification_confidence:
          type: number
          description: Present when classified_by = model (the model's own confidence) or adapter (always 1.0 — a structural fact, not an estimate).
        is_directive:
          type: boolean
          description: Directive class marker, copied from the taxonomy entry at classification time.
        review_status:
          type: string
          enum:
            - auto
            - needs_review
            - confirmed
        detected_type:
          type: string
          description: |-
            The shadow proposal, when an uploader's assertion is the effective type — the model's reading, or the parsing adapter's own class, in which case `detected_confidence` is 1.0. Absent when no shadow reading ran.

            WHICH ONE WINS. An uploader's assertion always stays effective. An adapter class that disagrees either CONTESTS it — raising `asserted_detected_conflict` and putting the document into `needs_review` — or YIELDS to it silently, with no flag and no review, which is what a format's default class does when it only knows the shape of the payload. So this field can differ from `doc_type` with nothing flagged.
        detected_confidence:
          type: number
        detected_party:
          type: string
        classification_model:
          type: string
          description: "PLATFORM-ONLY (internal operators). Registry model key that produced the shadow/effective model proposal. `classified_by` is deliberately NOT stripped: it names the mechanism CLASS (model vs uploader vs human), which is the provenance the review UI must show, not a vendor identity."
        classification_flags:
          type: array
          items:
            type: string
          description: "Flag strings — e.g. assertion_unbound, asserted_detected_conflict, directive_ambiguity, shadow_skipped_governance. Empty is not the same as \"nothing disagreed\": a vertical DEFAULT that lost to an uploader's assertion yields silently and raises no flag; the override is recorded in the document.classified audit event, not here."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    LogicalDocumentList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/LogicalDocument"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    ClassifyLogicalDocumentRequest:
      type: object
      required:
        - doc_type
      properties:
        doc_type:
          type: string
          description: Must be an active entry in the tenant's taxonomy (422 otherwise).
        party:
          type: string
        period:
          type: string
    StructuredFact:
      type: object
      description: One structured fact with provenance. `canonical_key` is absent for a raw, unmapped fact (always citable/viewable, never computable).
      required:
        - id
        - entity_ref
        - value
        - value_type
        - source_format
        - path
        - document_id
        - created_at
      properties:
        id:
          type: string
          format: uuid
        document_id:
          type: string
          format: uuid
        canonical_key:
          type: string
        entity_ref:
          type: string
          description: Which entity this fact belongs to, in the `entity:ordinal(/sub_entity:ordinal)*` grammar.
        value: {}
        value_type:
          type: string
          enum:
            - string
            - boolean
            - integer
            - decimal
            - date
            - enum
            - ref
        source_format:
          type: string
          description: The registry key of the adapter that produced this fact (e.g. xml-generic).
        path:
          type: string
          description: Absolute source-path provenance (XPath/JSONPath/CSV row).
        byte_offset_start:
          type: integer
        byte_offset_end:
          type: integer
        raw_snippet:
          type: string
          description: The raw source bytes this fact was typed from, capped at 256 bytes.
        created_at:
          type: string
          format: date-time
    StructuredFactList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/StructuredFact"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    SnapshotEntity:
      type: object
      required:
        - entity_ref
        - entity_type
        - ordinal
        - facts
      properties:
        entity_ref:
          type: string
        entity_type:
          type: string
          description: The leaf segment's entity type (e.g. "party", "employment").
        ordinal:
          type: integer
        parent_ref:
          type: string
          description: The owning entity's entity_ref; absent for a top-level entity.
        facts:
          type: array
          items:
            $ref: "#/components/schemas/StructuredFact"
      description: One entity's worth of facts, parsed from the `entity:ordinal(/sub_entity:ordinal)*` reference grammar.
    Snapshot:
      type: object
      required:
        - entities
      properties:
        entities:
          type: array
          items:
            $ref: "#/components/schemas/SnapshotEntity"
    ExpectationContributor:
      type: object
      description: One question claiming a doc type at that type's final (strongest) state — the "why is this expected" provenance. At most 5 are listed per expectation; contributor_count on the expectation carries the uncapped total.
      required:
        - question_lineage_id
        - text_preview
        - source
      properties:
        question_lineage_id:
          type: string
          format: uuid
        text_preview:
          type: string
          description: The first ~140 characters of the question's text_md.
        source:
          type: string
          enum:
            - global
            - supplemental
        directive_document_id:
          type: string
          format: uuid
          description: The directive document that introduced this question (source=supplemental only).
    DocumentExpectation:
      type: object
      description: "One doc type's question-derived, gate-aware expectation (the shared derivation's unit, served on both completeness surfaces). State is the strongest claim across the effective questions: required (unconditional or gate-resolved-true) > conditional (behind a gate promoted facts cannot resolve — the panel resolves it downstream, never a failure) > not_required (behind a gate deterministically resolved false). Advisory, always."
      required:
        - doc_type
        - state
        - present
        - source
      properties:
        doc_type:
          type: string
        display_name:
          type: string
          description: The taxonomy's display name for this doc type, resolved at read time; absent when the type is not in the tenant's taxonomy.
        state:
          type: string
          enum:
            - required
            - conditional
            - not_required
        present:
          type: boolean
          description: Whether the scenario's current evidence carries this doc type (classified non-directive logical documents, plus fact-only structured evidence documents counted by their uploader-declared type).
        present_count:
          type: integer
          description: How many evidence documents carry this document type. Absent on an expectation read back from an older run's checkpoint, which recorded only whether something was present.
        source:
          type: string
          enum:
            - global
            - supplemental
          description: The channel that introduced the expectation; global dominates when both claim it.
        directive_document_id:
          type: string
          format: uuid
          description: The directive document behind a supplemental-sourced expectation.
        contributors:
          type: array
          maxItems: 5
          items:
            $ref: "#/components/schemas/ExpectationContributor"
          description: The questions claiming this doc type at its final state (capped at 5).
        contributor_count:
          type: integer
          description: The uncapped total number of contributing questions.
        directive_present_count:
          type: integer
          description: "Absent when none do. A directive document is a rule source and never evidence, so it is excluded from `present`/`present_count` exactly as before; this count is an HONESTY ANNOTATION, not a satisfaction signal. Load-bearing when `present` is false: the type is not waiting on an upload — documents of it are already here and can never satisfy an evidence requirement, so the question asking for it needs re-authoring (the publish-time authoring lint reports that as `directive_evidence_doc_type`)."
        directive_review_pending_count:
          type: integer
          description: The subset of `directive_present_count` whose classification is not yet auto/confirmed, and which is therefore not acting as a rule source yet either. Absent at zero.
    UnexpectedDocument:
      type: object
      description: A classified, non-directive logical document whose doc type no effective question claims at any state — still fully classified and swept, not a problem.
      required:
        - logical_document_id
        - doc_type
      properties:
        logical_document_id:
          type: string
          format: uuid
        doc_type:
          type: string
        display_name:
          type: string
          description: The taxonomy's display name for this doc type, resolved at read time.
    ScenarioCompletenessReport:
      type: object
      description: "The scenario-level advisory completeness preview: what an evaluation against this question set started NOW would expect, against the scenario's current evidence and promoted facts. Never blocks anything."
      required:
        - question_set_id
        - question_set_version_id
        - advisory
        - expectations
        - unexpected_documents
      properties:
        question_set_id:
          type: string
          format: uuid
        question_set_version_id:
          type: string
          format: uuid
          description: The latest published version the preview derived from.
        advisory:
          type: boolean
          enum:
            - true
          description: Literal honesty marker — completeness is advisory by doctrine; it never gates.
        expectations:
          type: array
          items:
            $ref: "#/components/schemas/DocumentExpectation"
        unexpected_documents:
          type: array
          items:
            $ref: "#/components/schemas/UnexpectedDocument"
    ProgressStageState:
      type: string
      description: One stage's position in a run's ordered stage sequence. `paused` marks the stage a resumable run stopped in (budget/ degradation/vendor pause); a completed stage renders full regardless of its unit counts (some stages record no units — e.g. a no-op consistency sweep).
      enum:
        - pending
        - active
        - paused
        - completed
        - failed
    ProgressStage:
      type: object
      description: |-
        One stage of a run's ordered stage sequence (determinate progress). units_done counts this stage's durably checkpointed work units so far — checkpoint-derived, so monotonically non-decreasing while the run is live, including under the parallel fan-out. units_total is ABSENT until the stage's plan exists (e.g. a generation run's batch count exists only after extraction; an evaluation's panel plan only after bundling) — a client renders such a stage as pending/count-only, never a fabricated total.
        Both counts always speak the SAME unit, so units_done never exceeds units_total. For an evaluation, a `panel` unit is one seat's ANSWER for one evidence set — a large set whose answer is fetched as several calls is still one unit, and it moves the count when the whole answer lands — and a `consensus` unit is one composed answer, counted against every question the run will answer, not only the ones an evidence set contains.
      required:
        - key
        - label
        - state
      properties:
        key:
          type: string
        label:
          type: string
          description: Human-readable stage name (render this, not the key).
        units_done:
          type: integer
        units_total:
          type: integer
        state:
          $ref: "#/components/schemas/ProgressStageState"
        execution_mode:
          type: string
          enum:
            - interactive
            - batch
          description: |-
            HOW this stage's units execute, when the distinction exists. Present only on an evaluation's `panel` stage, and only while the run is non-terminal; ABSENT everywhere else — absent means "no such notion here", never "interactive".
            `interactive` is one synchronous provider call per unit, so `units_done` climbs steadily and a per-stage bar is meaningful. `batch` is the provider-batch path (~50% cheaper, minutes to hours of provider-side latency): the seat's units are submitted together and CHECKPOINT TOGETHER when the batch returns, so `units_done` legitimately sits at its starting value for the whole wait and then jumps. Render that stage as indeterminate with a stated reason ("running as a batch — results arrive together"), NOT as a stalled `0 of N`, which is what the same payload looked like before this field existed and is indistinguishable from a wedged run.
            Sourced from the run's pinned `budget_snapshot.execution_mode` (the per-run `execution_mode` override at start, else the tenant's `evaluation_policy.batch_default`) through the same resolver the executor dispatches on, so the mode published here and the path actually taken cannot disagree. Individual seats whose route has no batch API still fall back to interactive — surfaced separately on the `evaluation.batch_fallback` audit and its progress frame.
    RunProgress:
      type: object
      description: Current_stage names the active/paused stage's key; absent before the first stage starts and once the run is terminal. spend_so_far is the run's LLM-ledger total in USD so far; it is platform-role-only (financial figures never reach a tenant's own members) and never rides the scenario SSE stream. spend_ceiling is the enforced hard cap where one exists (the evaluation budget snapshot's hard cap / the generation run's per-run ceiling) so admin surfaces can render spend-vs-ceiling; absent means unlimited (spend-only display).
      required:
        - stages
      properties:
        stages:
          type: array
          items:
            $ref: "#/components/schemas/ProgressStage"
        current_stage:
          type: string
        spend_so_far:
          type: number
          format: double
        spend_ceiling:
          type: number
          format: double
        spend_withheld:
          type: boolean
          description: |-
            TRUE means spend_so_far/spend_ceiling are absent BECAUSE your principal may not see financial figures (they are platform-role-only), not because there is nothing to report. It says "not for you"; it never says how much, and it is not itself a financial figure. Absent/false means no withholding is in play, so an absent figure above is genuinely absent (a queued run, an unlimited ceiling).
            Render it as a stated withholding ("visible to platform operators"), never as $0 and never as an error.
            The same withholding governs `GenerationRunStats.cost_usd` / `budget_limit_usd` on the payload this progress object rides, which is why the marker is published once here rather than on each field.
    EvaluationStatus:
      type: string
      description: Where a run is in its lifecycle.
      enum:
        - pending
        - running
        - paused_budget
        - paused_degraded
        - completed
        - failed
        - canceled
    RunScope:
      type: string
      description: "How much of a run's frozen effective question set it re-answers (pinned on the run, guard-frozen). `full` re-answers everything — today's behaviour, the default, and the degradation target. `delta` narrows the panel to the invalidation closure of what actually changed since the current answers were composed; everything outside the closure is carried by omission (no LLM call, no new answer row). `failed_flagged` additionally seeds every lineage whose current answer abstained or carries an unresolved flag, and composes with `delta`'s seeds. Orthogonal to WHY a run started (see `reevaluate_of`): the trigger is the motive, the scope is the size of the work."
      enum:
        - full
        - delta
        - failed_flagged
    EvaluationScopeSummary:
      type: object
      description: What a delta run's closure decided. Present on a run's status/results ONLY once its `delta_plan` step has executed, and only for a non-`full` scope — a full run has no closure to summarize, and `run_scope` on the evaluation already says so. Sourced from the run's `evaluation.delta_planned` audit event, which is written in the same transaction as the frozen plan checkpoint and, unlike that checkpoint (deleted at terminal state with every other step's), is permanent — so the summary reads identically while the run is in flight and forever after it completes.
      required:
        - run_scope
        - carried
        - re_answered
        - new
        - left_set
        - degraded_to_full
      properties:
        run_scope:
          $ref: "#/components/schemas/RunScope"
        carried:
          type: integer
          description: "Lineages whose prior answer stands unchanged — carried BY OMISSION: no row copied, no seat called, no ledger row written."
        re_answered:
          type: integer
          description: Lineages inside the closure — re-answered this run, superseding their prior answer.
        new:
          type: integer
          description: Lineages in this run's effective set with no prior answer at all.
        left_set:
          type: integer
          description: Lineages that had an answer but are no longer in this run's effective set (exempted by a directive, or dropped by a republish) — not re-answered, and correctly absent from this run's decision.
        sweep_scope:
          type: integer
          description: Lineages the flag-only consistency sweep covered — the closure plus neighbour expansion (a re-answered answer is checked against the unchanged answers it could contradict), so legitimately larger than `re_answered`.
        degraded_to_full:
          type: boolean
          description: True when the closure's preconditions failed and the run honestly re-answered everything (prompt/panel/config drift, missing digests on pre- answers, an unwalkable rulebook diff, erasure of carried evidence). A MARGIN event, never a billing event — the meter is delta-by-construction, so a degraded run still bills only never-before-metered document versions.
        degrade_reasons:
          type: array
          items:
            type: string
          description: The named triggers behind `degraded_to_full`; absent when it is false.
    Evaluation:
      type: object
      required:
        - id
        - scenario_id
        - seq
        - status
        - question_set_id
        - question_set_version_id
        - taxonomy_revision_id
        - created_at
      properties:
        id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        seq:
          type: integer
        status:
          $ref: "#/components/schemas/EvaluationStatus"
        question_set_id:
          type: string
          format: uuid
          description: The question set the pinned version belongs to. Derived from question_set_version_id (evaluations rows store only the version id); always populated. Lets a client join answers to the pinned version's question text without assuming the run used the caller's current set choice.
        question_set_version_id:
          type: string
          format: uuid
        taxonomy_revision_id:
          type: string
          format: uuid
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        progress:
          $ref: "#/components/schemas/RunProgress"
          description: Stage-sequenced progress (panel → consensus → support → rollup). Present on the single-run resources (GET one evaluation, the evaluate/resume 202s) and, without spend, on `evaluation.progress` SSE frames; absent on list rows (the list is a history surface, not a progress surface). spend_so_far/ spend_ceiling appear only for platform-role principals.
        run_scope:
          $ref: "#/components/schemas/RunScope"
          description: The scope this run was pinned to when it was created — frozen, so a resumed run replans identically and the record stays queryable forever. Always present; a run created before scopes existed reads as `full`.
        scope_summary:
          $ref: "#/components/schemas/EvaluationScopeSummary"
          description: What the closure decided, on the single-run resources only (GET one evaluation, the evaluate/resume 202s). Absent for a `full` run and for a delta run whose `delta_plan` step has not executed yet — absence means "no closure was computed", never "zero carried".
        panel_health:
          $ref: "#/components/schemas/EvaluationPanelHealth"
          description: "How healthy the answering panel actually was, on the single-run resources only (GET one evaluation, the evaluate/resume 202s) — absent on list rows, which are a history surface. `status` alone cannot answer this: a run whose lead seat is cut off at its output-token cap and abstains every node still reaches `completed`, because the surviving seat answered and every resulting answer was correctly withheld from auto-accept. READ THIS FIELD, not just `status`, before you present a run's answers as settled."
      description: One underwriting run against a case.
    EvaluationPanelHealth:
      type: object
      description: |-
        The answering panel's health for one run, derived from the append-only substrate (`consensus_records`) and the spend ledger (`llm_calls`) — never from the resume checkpoints, which are deleted when a run goes terminal, so these figures read the same a week later as they do mid-run.

        WHAT A CLIENT SHOULD DO. `degraded: true` means treat this run as needs-human-review and say why, using `reasons`. It never means the answers were auto-accepted anyway: degraded, seat-substituted and flagged answers are structurally excluded from auto-accept, and this block is the visible half of that, not a second weaker gate. `degraded: false` with all counts zero is NOT a claim of health — it means the run recorded no panel positions at all (it has not answered yet, or every answer is deterministic or carried from an earlier run).
      required:
        - degraded
        - reasons
        - seats_configured
        - seats_answered
        - answering_families
        - degraded_answers
        - truncated_calls
      properties:
        degraded:
          type: boolean
          description: True when `reasons` is non-empty — the one boolean to branch a "needs human review" banner on.
        reasons:
          type: array
          description: "Everything detected, in a stable order (truncation, then failed seats, then family collapse, then degraded answers). Empty when `degraded` is false. The vocabulary is closed and additive: treat an unrecognized value as a reason to review, never as no reason."
          items:
            type: string
            enum:
              - seat_truncated
              - seat_failed
              - single_family
              - degraded_consensus
            description: "`seat_truncated` — at least one seat call was cut off at its output-token cap, so its answers stop mid-sentence. `seat_failed` — a seat this run configured produced no verified position anywhere (truncated, refused, transport-dead, or repair-exhausted), so the panel was narrower than the tenant's policy asked for. `degraded_consensus` — at least one answer carries an unresolved `degraded_consensus` flag from the degradation ladder."
        seats_configured:
          type: integer
          format: int64
          description: Distinct panelist seats this run actually asked in round 1 (abstentions included — a seat that was asked and produced nothing still counts here, which is what makes `seat_failed` detectable).
        seats_answered:
          type: integer
          format: int64
          description: "How many of those seats produced at least one VERIFIED position: citations that existed in the supplied context and survived the checks. Deliberately the same bar the consensus protocol applies before it will stand on a seat's citations, so this can never claim a seat the run did not rely on."
        answering_families:
          type: integer
          format: int64
          description: Distinct model families spanned by the seats that answered. Fewer than two means no cross-family corroboration was possible.
        degraded_answers:
          type: integer
          format: int64
          description: Answers in this run carrying an unresolved `degraded_consensus` flag. Each one is already barred from auto-accept; this is the count, for a summary line.
        truncated_calls:
          type: integer
          format: int64
          description: How many of this run's model calls ended truncated against the output limit. A call covers a bounded chunk of questions, so one truncation costs that chunk rather than everything the seat was answering — but a non-zero count is still answers that were cut off.
      example:
        degraded: true
        reasons:
          - seat_truncated
          - seat_failed
          - single_family
          - degraded_consensus
        seats_configured: 2
        seats_answered: 1
        answering_families: 1
        degraded_answers: 46
        truncated_calls: 1
    EvaluationList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Evaluation"
        next_cursor:
          type: string
    EvaluateScenarioRequest:
      type: object
      properties:
        question_set_id:
          type: string
          format: uuid
          description: "Which question set to evaluate against; its current published version is pinned. 422 when omitted AND the scenario has no question set of its own, and 422 when the named set is written for a different vertical than the case (the run-time half of the same rule the binding endpoints enforce) or for a different program (the same run-time half one axis down: a case judged by another program's rulebook is exactly the bleed the program axis exists to prevent)."
        notify_on_completion:
          type: boolean
          description: Opt in to an email (to the requesting user) once this run reaches a terminal state (completed, failed, or canceled). Mirrors generate/diff-run's notify_on_completion sugar; additive alongside notifications below.
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscriptionInline"
          description: Inline task-notification subscriptions for this run's evaluation_run task kind.
        execution_mode:
          type: string
          enum:
            - batch
            - interactive
          description: Optional per-run override of the batch-vs-interactive execution path. Omitted inherits the tenant's evaluation-policy default (evaluation_policy.batch_default). Seats whose route has no batch API fall back to interactive, surfaced on the run (evaluation.progress + audit), never silently.
        reevaluate_of:
          type: string
          format: uuid
          description: "Re-eval-needed OFFER surface: name a prior COMPLETED evaluation of this scenario (surfaced as `stale` on its GET.../effective-question-set) this run responds to. Audited (`evaluation.reevaluate_triggered`, carrying the referenced evaluation's staleness reasons); 404/409 if it doesn't name a completed evaluation of THIS scenario. Starts the SAME full ScenarioEvaluation as any other evaluate call — no bundle-scoped partial re-answer exists yet, so this is honestly a full re-run, just one explicitly tied to what prompted it — unless it is combined with `run_scope: delta` below, which is what finally makes it cheap."
        run_scope:
          $ref: "#/components/schemas/RunScope"
          description: "How much of the frozen effective set this run re-answers. Composes with `reevaluate_of`, which keeps its offer/audit semantics. An unrecognised value is 422, never a silent downgrade to `full`: a caller who asked for a cheap run and got an expensive one would find out on the invoice. Preview what a scope would do with `GET.../reevaluation-plan` before spending anything."
    Report:
      type: object
      description: One PDF findings report — on-demand, immutable, pinned to a decision.
      required:
        - id
        - scenario_id
        - evaluation_id
        - decision_id
        - status
        - content_type
        - created_at
      properties:
        id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        evaluation_id:
          type: string
          format: uuid
        decision_id:
          type: string
          format: uuid
        status:
          $ref: "#/components/schemas/ReportStatus"
        renderer_version:
          type: string
          description: The pinned renderer identity (report template + Gotenberg image) that produced the bytes; empty until ready.
        content_type:
          type: string
        content_hash:
          type: string
          description: sha256 (hex) of the PDF bytes — the immutability proof; present once ready.
        size_bytes:
          type: integer
          format: int64
        failure_reason:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    ReportStatus:
      type: string
      enum:
        - pending
        - rendering
        - ready
        - failed
    ReportList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Report"
        next_cursor:
          type: string
    RequestReportRequest:
      type: object
      required:
        - decision_id
      properties:
        decision_id:
          type: string
          format: uuid
          description: Which decision to render the findings report over.
        notify_on_completion:
          type: boolean
          description: Opt in to an email (to the requesting user) once the report is ready (or fails). Mirrors evaluate's notify_on_completion sugar; additive alongside notifications below.
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscriptionInline"
          description: Inline task-notification subscriptions for this report's report_generated task kind.
    GetReportDownloadResponse:
      type: object
      required:
        - report
        - download_url
        - download_expires_at
      properties:
        report:
          $ref: "#/components/schemas/Report"
        download_url:
          type: string
          description: Presigned GET URL.
        download_expires_at:
          type: string
          format: date-time
    RoutingRecord:
      type: object
      description: One routing decision — a question lineage joined to the logical documents that answer it.
      required:
        - id
        - question_lineage_id
        - seq
        - routed_logical_document_ids
        - method
        - source
        - created_at
      properties:
        id:
          type: string
          format: uuid
        question_lineage_id:
          type: string
          format: uuid
        seq:
          type: integer
        routed_logical_document_ids:
          type: array
          items:
            type: string
            format: uuid
        method:
          type: string
          enum:
            - deterministic
            - model_assist
        source:
          type: string
          enum:
            - system
            - human_override
        created_at:
          type: string
          format: date-time
    EvaluationCompletenessSummary:
      type: object
      description: "The evaluation run's completeness report, read back verbatim from its checkpoint — the same derivation engine as the scenario-level ScenarioCompletenessReport, frozen at run time. ADVISORY: it never gated and never gates. required/present/ missing_types keep their original shape byte-compatibly; conditional_types / not_required_types are the gate-aware buckets (absent on pre- runs); expectations is the per-doc-type projection (synthesized without contributors/present counts for runs checkpointed before)."
      required:
        - required_types
        - present_types
        - missing_types
      properties:
        required_types:
          type: array
          items:
            type: string
        present_types:
          type: array
          items:
            type: string
        missing_types:
          type: array
          items:
            type: string
        conditional_types:
          type: array
          items:
            type: string
          description: Conditionally relevant (gate unresolvable from promoted facts — the panel resolves it downstream). Informational, never gating.
        not_required_types:
          type: array
          items:
            type: string
          description: Behind a gate deterministically resolved false (condition not met).
        expectations:
          type: array
          items:
            $ref: "#/components/schemas/DocumentExpectation"
    AnswerNode:
      type: object
      description: One evaluated node of a question's logic.
      required:
        - id
        - node_id
        - node_kind
        - result
        - source
        - abstained_seats
        - created_at
      properties:
        id:
          type: string
          format: uuid
        node_id:
          type: string
        node_kind:
          type: string
          enum:
            - check
            - gate
            - group
        result:
          type: string
          enum:
            - pass
            - fail
            - not_applicable
            - insufficient_evidence
        value:
          type: string
          description: "The answered value as reviewer-facing text, rendered server-side from the stored value per the question's answer schema (E7, S8 wave 2): booleans as \"Yes\"/\"No\", numbers as their decimal text, enum/text/date values as their text. Absent when the node recorded no value (group rollups, abstentions). BREAKING (E7, pre-approved): this field previously carried the stored value's wire encoding verbatim; that moved to the platform-only `value_raw` — no client decodes answer values anymore."
        value_raw:
          description: "PLATFORM-ONLY (internal operators). The stored answered value verbatim — the raw JSON the evaluator recorded, forensic detail, never customer prose. ABSENT for every non-platform principal, exactly like DecisionRationale.bindings and ConsensusRecord's model-identity triple: a tenant's own admin is the customer-facing ceiling and sits BELOW this boundary. Also absent (for every principal) when the node recorded no value — an empty value here would be a false claim of an answered empty value."
        branch_taken:
          type: string
        source:
          type: string
          enum:
            - panel
            - deterministic
            - human_override
            - carried
        origin_answer_node_id:
          type: string
          format: uuid
        agreement_pattern:
          type: string
          enum:
            - unanimous
            - reconsidered_unanimous
            - majority_held
            - adjudicated
            - degraded
        confidence:
          type: number
          description: Calibrated P(correct), 3 decimal places.
        abstained_seats:
          type: integer
        created_at:
          type: string
          format: date-time
    AnswerEvidence:
      type: object
      description: Node-keyed citation, exactly one evidence family.
      required:
        - id
      properties:
        id:
          type: string
          format: uuid
        answer_node_id:
          type: string
          format: uuid
        document_id:
          type: string
          format: uuid
        block_ids:
          type: array
          items:
            type: string
            format: uuid
        page:
          type: integer
        bbox: {}
        quote:
          type: string
        structured_fact_id:
          type: string
          format: uuid
        extraction_path:
          type: string
    AnswerFlag:
      type: object
      description: |-
        One OPEN review marker the run recorded against an answer (answer_flags). A flag is something the run itself noticed and could not settle on its own, surfaced so a reviewer reads it directly instead of re-deriving it from the transcript.
        `citation_support_unverified` is the step-2 citation support check under Amendment 2026-08-01: a cheap-model pass could not confirm that the cited evidence actually supports the position, and the verdict was NOT one of the disqualifying ones (contradicted / asserted_only, which still void the position). The answer SURVIVES and is marked for human review — what needs checking is the citation, not whether the answer should exist. Before the amendment the same signal abstained the position outright, which erased answers instead of surfacing them.
        Some markers ask for a human; others are a note on an answer that stands. Both are recorded, listed and resolvable identically; only whether the marker ALONE holds the answer differs.
      required:
        - kind
        - created_at
        - resolved
        - review_gating
      properties:
        kind:
          type: string
          description: "The flag vocabulary (answer_flags.kind): consistency_contradiction | judge_upgrade | degraded_seat | degraded_consensus | map_reduce_context | review_required | budget_degraded | citation_support_unverified. Deliberately a plain string, not a wire enum — the closed set lives in the database CHECK, and a client must render an unrecognized kind (humanized) rather than drop the marker it does not know."
        detail:
          type: object
          additionalProperties: true
          description: "Kind-specific context, free-form by design. For citation_support_unverified it carries `reason` (a plain sentence), `code` (the support verdict), `seats` (which panel families held the unconfirmed citation), `verifier` (the checking model's own words) and `amendment`. Absent when the kind records no context. IDENTITY-REDACTED for non-platform principals: keys naming an AI model/provider/family — including the family names under `seats` — are removed before the blob is serialised. `reason`, `code` and `verifier` (the words, not the identity) survive."
        created_at:
          type: string
          format: date-time
        resolved:
          type: boolean
          description: True once a reviewer closed the flag. The results surface returns open flags only, so it is false on everything read from there — the field is present so a client never has to infer resolution state from a flag's absence.
        review_gating:
          type: boolean
          description: |-
            Whether this marker, ON ITS OWN, puts the answer in the needs-a-human set. True is the ordinary case (unverified citations, panel disagreement, a degraded panel).
            Computed server-side from the same list the summary endpoint's counting queries use (app.footnoteFlagKinds), so a client's per-flag styling and the aggregate `verdict_counts` can never disagree about which tier a marker is in. A false here NEVER means the answer is unreviewed: verdict, abstention and decision-driving all still gate independently, so a footnote on a failed answer leaves it firmly in `needs_review`.
    ConsensusCitation:
      type: object
      description: |-
        One citation a panel seat emitted for this position, exactly as stored on consensus_records.evidence, resolved server-side to a target the evidence viewer can open (citation currency: a layout block id or a structured-fact path).
        UNRESOLVABLE REFS ARE NEVER OMITTED. A ref that does not resolve against the run's assembled evidence comes back with resolved=false and no target, because a citation a panelist produced that resolves to nothing is precisely the audit signal — a fabricated id, an id lifted from another document, or a lure id planted in document text. Dropping it would erase the evidence trail that signal lives in, so this surface reports it instead.
      required:
        - kind
        - ref
        - resolved
      properties:
        kind:
          type: string
          enum:
            - block
            - fact_path
          description: Which kind of thing the citation points at. Note `fact_path` here, rather than `DecisionCitation`'s `fact`.
        ref:
          type: string
          description: Layout block UUID (kind=block) or extraction fact path (kind=fact_path), verbatim as the seat emitted it.
        resolved:
          type: boolean
          description: True when the ref was found in the run's evidence and the target fields below are populated. False means the ref points at nothing — see this schema's description.
        document_id:
          type: string
          format: uuid
          description: The document the cited identifier lives in — what the evidence viewer needs to open it. Present only when resolved.
        page:
          type: integer
          description: 1-based page of the cited layout block. Present only for a resolved kind=block citation.
        structured_fact_id:
          type: string
          format: uuid
          description: The structured fact the cited path resolved to (mirrors AnswerEvidence.structured_fact_id). Present only for a resolved kind=fact_path citation.
    ConsensusRecord:
      type: object
      required:
        - id
        - role
        - seat
        - round
        - created_at
      properties:
        id:
          type: string
          format: uuid
        answer_node_id:
          type: string
          format: uuid
        family:
          type: string
          description: 'PLATFORM-ONLY (internal operators). Config-defined diversity unit (claude/gpt/gemini) — free text, never a fixed vendor enum. Optional (not required) precisely so the field can be OMITTED rather than blanked: `""` would be a false claim of an empty family, and clients must be able to tell "not disclosed to you" from "no value". Panelist anonymisation does NOT depend on it — display names derive from `seat`/`role`, which stay required.'
        provider:
          type: string
          description: PLATFORM-ONLY; absent for every non-platform principal. See `family`.
        model:
          type: string
          description: PLATFORM-ONLY; absent for every non-platform principal. See `family`.
        role:
          type: string
          enum:
            - panelist
            - reconsideration
            - adjudicator
            - judge
            - verifier
            - sweep
        seat:
          type: integer
        round:
          type: integer
        result:
          type: string
          enum:
            - pass
            - fail
            - not_applicable
            - insufficient_evidence
        confidence_raw:
          type: number
          description: Seat self-score — recorded as a feature, never trusted.
        rationale_md:
          type: string
        evidence:
          type: array
          items:
            $ref: "#/components/schemas/ConsensusCitation"
          description: This seat's own citations, resolved to viewable targets. Absent when the seat cited nothing (an abstention, or a node with no evidence requirement) — never an empty array standing in for "not loaded".
        evidence_verified:
          type: boolean
        evidence_unmatched:
          type: boolean
          description: "True when at least one identifier this seat cited could not be matched to the evidence the seat was shown. It is what separates the two very different things an `insufficient_evidence` seat result can mean: the panelist itself judged the evidence insufficient, or the panelist gave an answer that was thrown away because a citation would not resolve. Absent — not false — when no citation was checked at all, which is the honest reading of a seat that cited nothing."
        evidence_repaired:
          type: boolean
          description: "True when at least one identifier this seat cited did not resolve as written and was matched to a UNIQUE identifier already present in the seat's own evidence context (a transcription slip in a 36- character id). The match is never a guess — an ambiguous or distant ref is rejected outright — and never silent: `verification_detail` carries the original ref, what it was matched to, how far apart they were and which rule matched them. Absent when no citation was checked."
        verification_detail:
          type: object
          additionalProperties: true
          description: What the verification actually found for THIS seat, citation by citation. `checks` holds one entry per emitted citation (kind, ref, present, plus a reason when it is not present); `support` is the step-2 support verdict, present only when that check flagged the position (flagged, code, reasoning). A check whose ref was matched only after repair also carries `repaired_ref`, `repair_distance` and `repair_method` (`normalized` | `edit_distance` | `contiguous_gap`); `ref` always stays exactly what the seat emitted. Absent when the seat cited nothing and no support check ran — never an empty object standing in for "not loaded".
        agreed:
          type: boolean
        created_at:
          type: string
          format: date-time
      description: "One transcript row: a single seat, in a single round, on a single question. This is the data behind the discussion view."
    Answer:
      type: object
      description: One answer, at the level of a question's lineage.
      required:
        - id
        - evaluation_id
        - scenario_id
        - question_lineage_id
        - seq
        - source
        - result
        - auto_accepted
        - created_at
      properties:
        id:
          type: string
          format: uuid
        evaluation_id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        question_id:
          type: string
          format: uuid
        scenario_question_id:
          type: string
          format: uuid
        question_lineage_id:
          type: string
          format: uuid
        seq:
          type: integer
        source:
          type: string
          enum:
            - consensus
            - deterministic
            - human_override
        result:
          type: string
          enum:
            - pass
            - fail
            - not_applicable
            - insufficient_evidence
        confidence:
          type: number
          description: Calibrated P(correct); absent for deterministic/human_override answers.
        notes_md:
          type: string
        agreement_pattern:
          type: string
          enum:
            - unanimous
            - reconsidered_unanimous
            - majority_held
            - adjudicated
            - degraded
        auto_accepted:
          type: boolean
        superseded_by:
          type: string
          format: uuid
          description: "Set once this answer is superseded — points at the successor (e.g. an override). The superseded chain: results returns every answer for the run, so the chain is walkable via override_of / superseded_by."
        override_of:
          type: string
          format: uuid
          description: For a human_override answer, the answer it overrode.
        override_reason:
          type: string
          description: MANDATORY on a human_override answer.
        created_by:
          type: string
          format: uuid
          description: The user who produced this answer — the reviewer principal on a human_override answer (provenance). Absent for system/workflow-produced answers.
        created_at:
          type: string
          format: date-time
    AnswerDetail:
      type: object
      description: One answer with its full node/evidence/consensus detail (the results view's per-question unit).
      required:
        - answer
        - nodes
        - evidence
        - consensus
        - needs_review
      properties:
        answer:
          $ref: "#/components/schemas/Answer"
        created_by_display_name:
          type: string
          description: "Read-time display resolution of `answer.created_by` (rule 3 — no raw internals): the creating user's display name (email when the display name is blank) — the reviewer principal on a human_override answer. Absent for system/workflow-produced answers and when the user row no longer resolves (e.g. a deleted user); clients then fall back to role wording, never rendering a raw id as a name. Never part of the stored answer row — enrichment happens on read; answers stay append-only."
        needs_review:
          type: boolean
          description: "Whether this answer is actually asking for a person, computed server-side with exactly the same definition the summary endpoint uses: true when the result is `fail` or `insufficient_evidence`, when the answer carries an unresolved review-gating flag (a footnote-level marker does not qualify), or when it is decision-driving — named by a rationale entry of this run's newest decision. Everything else renders as accepted without review. Distinct from `auto_accepted`, which is the calibration machinery's own record and still refuses every flagged answer of either kind."
        nodes:
          type: array
          items:
            $ref: "#/components/schemas/AnswerNode"
        evidence:
          type: array
          items:
            $ref: "#/components/schemas/AnswerEvidence"
        consensus:
          type: array
          items:
            $ref: "#/components/schemas/ConsensusRecord"
        flags:
          type: array
          items:
            $ref: "#/components/schemas/AnswerFlag"
          description: This answer's OPEN review markers, oldest first. Absent when the run recorded no flag against this answer, or when every flag it recorded has since been resolved — never an empty array standing in for "not loaded".
        provenance:
          $ref: "#/components/schemas/AnswerProvenance"
          description: Whether this run PRODUCED the answer or CARRIED it. Always `fresh` on a `full` run — everything was answered. On a delta run the results view is the union of both (see EvaluationResults), and this is the only field that distinguishes them; a `carried` answer's own `evaluation_id` still points at the run that produced it, which is the honest provenance and not a bug.
    AnswerProvenance:
      type: string
      description: Whether an answer in a run's results was produced by that run (`fresh`) or carried into it unchanged from an earlier one (`carried`).
      enum:
        - fresh
        - carried
    EvaluationResults:
      type: object
      description: |-
        `answers` is legitimately empty until the panel-answering steps land — the spine only routes.
        for a DELTA run `answers` is the UNION of the answers this run composed and the answers it carried, so the view shows the full picture the run's decision was actually computed over rather than only the fraction that moved. Membership is exactly the rollup's rule — this run's own answers, plus the scenario-current answer of every other lineage in this run's frozen effective set — with `provenance` on each entry naming which side it came from. A `full` run is unchanged: every answer is its own, and every one is `fresh`.
      required:
        - evaluation
        - routing
        - answers
      properties:
        evaluation:
          $ref: "#/components/schemas/Evaluation"
        routing:
          type: array
          items:
            $ref: "#/components/schemas/RoutingRecord"
        completeness:
          $ref: "#/components/schemas/EvaluationCompletenessSummary"
        scope_summary:
          $ref: "#/components/schemas/EvaluationScopeSummary"
          description: The closure summary for a delta run (same object, same source as on the Evaluation) — how many of the answers below were carried rather than composed. Absent for a `full` run.
        answers:
          type: array
          items:
            $ref: "#/components/schemas/AnswerDetail"
    EvaluationResultsSummary:
      type: object
      description: "GET.../results/summary's shape: the SQL-side aggregation the results surface renders judgment from. All counts and the attention list consider each lineage's CURRENT answer within this run's results view — exactly GET.../results' membership (a delta run's carried answers included), so the two surfaces can never name different sets."
      required:
        - evaluation_id
        - scenario_id
        - verdict_counts
        - needs_attention
        - total_attention
        - topics
        - confidence_uncalibrated
      properties:
        evaluation_id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        verdict_counts:
          $ref: "#/components/schemas/EvaluationVerdictCounts"
        confidence_uncalibrated:
          type: boolean
          description: True when at least one answer in this run carries the UNRESOLVED calibration-bootstrap marker — the run's confidence was never calibrated because the calibration bucket had no fitted curve yet (the normal state of a fresh tenant). That marker rides on EVERY answer while it holds, so the D5 needs-review predicate deliberately ignores it (it would re-create the blanket review wall D5 exists to remove); this one run-level boolean is where the fact is stated instead, ONCE, for a quiet caveat next to the verdict counts. A genuine per-answer review marker of the same kind (panel disagreement) is unaffected and still counts.
        needs_attention:
          type: array
          items:
            $ref: "#/components/schemas/EvaluationAttentionAnswer"
          description: The answers a human should actually look at (the needs-review set), most decision-relevant first (decision-driving, then fails, then abstentions, then flagged), CAPPED at 50 rows — total_attention carries the uncapped count.
        total_attention:
          type: integer
          description: The uncapped size of the needs-review set (== verdict_counts.needs_review).
        topics:
          type: array
          items:
            $ref: "#/components/schemas/EvaluationTopicSummary"
          description: Per-topic verdict groups keyed on the question model's `category` (the one grouping questions really carry), sorted by key. A run whose questions carry no category collapses to the single empty-key group.
    EvaluationVerdictCounts:
      type: object
      description: Verdict counts over each lineage's CURRENT answer in this run's results view, in the answers model's own result vocabulary (insufficient_evidence is the stored value the UI renders as "No answer"). needs_review/auto_accepted are the honest review split and always sum to `total`; `overridden` counts current answers a human overrode (source=human_override, a subset of the result buckets); `notes` counts answers carrying a footnote-tier marker (a subset of `auto_accepted`); `superseded` counts this run's superseded rows — history outside `total`, walkable in GET.../results.
      required:
        - total
        - pass
        - fail
        - not_applicable
        - insufficient_evidence
        - needs_review
        - auto_accepted
        - overridden
        - superseded
        - notes
      properties:
        total:
          type: integer
        pass:
          type: integer
        fail:
          type: integer
        not_applicable:
          type: integer
        insufficient_evidence:
          type: integer
        needs_review:
          type: integer
        auto_accepted:
          type: integer
        overridden:
          type: integer
        superseded:
          type: integer
        notes:
          type: integer
          description: 'How many answers carry at least one OPEN footnote-tier marker (AnswerFlag.review_gating=false) and are NOT in the needs-review set — "passed, with a note". A SUBSET of `auto_accepted`, never of `needs_review`: an answer a human must look at is counted there and only there, however many notes also ride on its row. Like `overridden` it is a subset marker, so it must never be added to the needs_review/auto_accepted split.'
    EvaluationAttentionAnswer:
      type: object
      description: One needs-review answer with enough to render a row without loading the full results.
      required:
        - answer_id
        - question_lineage_id
        - result
        - source
        - reasons
      properties:
        answer_id:
          type: string
          format: uuid
        question_lineage_id:
          type: string
          format: uuid
        question_text:
          type: string
          description: The pinned question's text, truncated server-side (~200 runes). Absent when the pinned question row cannot be resolved.
        topic:
          type: string
          description: The question's category key; empty for a question carrying none.
        result:
          type: string
          enum:
            - pass
            - fail
            - not_applicable
            - insufficient_evidence
        source:
          type: string
          enum:
            - consensus
            - deterministic
            - human_override
          description: Mirrors Answer.source — human_override marks a row a reviewer already judged.
        reasons:
          type: array
          items:
            type: string
          minItems: 1
          description: WHY this answer needs review, sorted and deduplicated. Plain strings, not a wire enum (the AnswerFlag.kind precedent) — today's vocabulary is verdict_fail | no_answer | flagged | decision_driving, and a client meeting an unfamiliar reason must render it rather than drop it.
        flag_kinds:
          type: array
          items:
            type: string
          description: "The UNRESOLVED REVIEW-GATING flags' kinds on this answer (answer_flags vocabulary), sorted. Absent when it carries none. Footnote-tier markers are omitted here for the same reason the bootstrap marker is: this list names what put the row in the attention set. The full flag list, both tiers, is on the answer itself in GET.../results."
    EvaluationTopicSummary:
      type: object
      description: One question-category group's verdict counts (same counting rules as EvaluationVerdictCounts, minus the run-level superseded bucket).
      required:
        - topic
        - total
        - pass
        - fail
        - not_applicable
        - insufficient_evidence
        - needs_review
      properties:
        topic:
          type: string
          description: The question model's `category` key; empty for questions carrying none (render as a general group).
        total:
          type: integer
        pass:
          type: integer
        fail:
          type: integer
        not_applicable:
          type: integer
        insufficient_evidence:
          type: integer
        needs_review:
          type: integer
    ClosureDisposition:
      type: string
      description: One lineage's verdict in an invalidation closure. `carry` — the prior answer stays current, zero LLM calls and zero rows. `re_answer` — inside the closure, so the prior answer can no longer be trusted. `new` — in the effective set with no prior answer at all. `leaves_set` — had an answer but is not in this run's effective set (directive-exempted, or dropped by a republish); no work, and correctly absent from the decision.
      enum:
        - carry
        - re_answer
        - new
        - leaves_set
    ReevaluationPlanLineage:
      type: object
      description: One lineage's would-be disposition in the previewed closure.
      required:
        - lineage_id
        - disposition
      properties:
        lineage_id:
          type: string
          format: uuid
        disposition:
          $ref: "#/components/schemas/ClosureDisposition"
        reasons:
          type: array
          items:
            type: string
          description: "WHICH seeds put this lineage where it is, sorted and deduplicated (`document_change`, `question_change`, `directive_change`, `routing_diff`, `digest_missing`, `digest_mismatch`, `evidence_erased`, `failed_flagged`, `no_prior_answer`, `human_override`, `left_effective_set`, `scope_full`, `config_drift`, `question_diff_unrecoverable`). A raw string, not an enum: the vocabulary grows with the closure, and a client meeting an unfamiliar reason must still show it rather than swallow the one signal explaining an unexpected re-run. Absent on a plain carry."
        attributed_seed:
          type: string
          description: The SINGLE seed this lineage's documents would be attributed to for the graduated rulebook-change rebill, applying the document-beats-question tie-break. Absent when the re-answer is not attributable — including the genuinely ambiguous routing-diff-only case, because billing an ambiguity is not defensible.
        whole_lineage:
          type: boolean
          description: True when every node of the lineage would re-answer.
        carried_answer_id:
          type: string
          format: uuid
          description: On a `carry`, the exact answer row the plan would stand on. Present so a caller can see WHAT is being reused, and so a concurrent supersession of it is explicable after the fact.
    ReevaluationMeteredDocument:
      type: object
      description: One document version the previewed run would meter.
      required:
        - document_id
        - logical_document_count
        - reason
      properties:
        document_id:
          type: string
          format: uuid
        logical_document_count:
          type: integer
          description: How many logical documents this version is metered as. A single file holding several statements counts as several; a facts-only evidence file counts as one.
        reason:
          type: string
          description: initial | document_added | question_change_rebill (invoice-legibility vocabulary). `question_change_rebill` only appears inside `rebill_documents`, never in `documents`.
    ReevaluationMeteringPreview:
      type: object
      description: "What the previewed run would METER, computed by the same pure planner the completion transaction uses (billing.PlanUsageEvent) against the scenario's live inventory and its already-metered set — so preview and invoice cannot disagree. delta semantics: a document version meters ONCE per scenario lifecycle, so an unchanged re-run previews `document_count: 0` and an empty `documents` list."
      required:
        - first_for_scenario
        - document_count
        - total_document_count
        - lifecycle_document_count
        - documents
        - rebill_documents
      properties:
        first_for_scenario:
          type: boolean
          description: Whether this run would carry the once-per-lifecycle base charge — true only when the scenario has no prior usage event.
        document_count:
          type: integer
          description: Metered logical documents this run would bill (the delta). Zero for an unchanged re-run.
        total_document_count:
          type: integer
          description: "The full evaluated scope — analytics/calibration, never billing math. Deliberately SCENARIO-scoped rather than narrowed to the closure (decision): a delta run's decision genuinely spans the whole scenario via its carried answers."
        lifecycle_document_count:
          type: integer
          description: Cumulative unique-version metered logical documents for the scenario AFTER this run.
        documents:
          type: array
          items:
            $ref: "#/components/schemas/ReevaluationMeteredDocument"
          description: The newly-metered versions, ordered by document_id. Empty when nothing new would bill.
        rebill_documents:
          type: array
          items:
            $ref: "#/components/schemas/ReevaluationMeteredDocument"
          description: "The `question_change_rebill` document versions this run would emit (F51b) — the OVERHANG past the tenant's contract threshold and nothing more, rounded DOWN to whole versions. Separate from `documents` because rebill rows never touch `document_count` or `lifecycle_document_count`. Empty is the normal answer: below the threshold, no active contract, or a rulebook that did not move since the scenario's last metered run."
        accrual:
          $ref: "#/components/schemas/ReevaluationAccrualPreview"
          description: "The rulebook-change accrual state this run would record. Absent entirely when the run would accrue nothing: no prior metered run for this scenario, or the same pinned question-set version as the scenario's last metered run (the A-30 anti-double-accrual guard)."
    ReevaluationAccrualPreview:
      type: object
      required:
        - changed_lineage_count
        - effective_set_size
        - accrued_bps
        - cumulative_bps_after
        - threshold_bps
      properties:
        changed_lineage_count:
          type: integer
          description: Modified + added lineages in this run's frozen effective set, directive-driven changes excluded (they are scenario-level instructions, not rulebook churn).
        effective_set_size:
          type: integer
          description: The frozen effective set's size — the denominator changed_lineage_count is measured against.
        accrued_bps:
          type: integer
          description: This run's own churn in basis points of the effective set (10000 * changed / size, truncated customer-favourably).
        cumulative_bps_after:
          type: integer
          description: "The scenario's lifecycle rulebook-churn total after this run: the prior cumulative plus accrued_bps. Not bounded at 10000 — lifetime churn can legitimately exceed 100% of one set's worth."
        threshold_bps:
          type: integer
          nullable: true
          description: The tenant's active billing contract's `question_change_rebill_threshold_bps` at preview time; null when there is no active contract — the accrual-only case, where the churn would be recorded but nothing would bill.
      description: What this run would be metered for if it ran now, under the graduated charge for a change to the rules. It is computed by the same code the completion path uses, so a preview and the charge an actual run records cannot disagree.
    ReevaluationPlanPreview:
      type: object
      description: The read-only dry run of a delta re-evaluation's invalidation closure. Computed from the SAME planning path the `delta_plan` step runs, against the scenario as it stands now and the question-set version a run started now would pin — with routing derived in memory instead of read from a run's routing records, because there is no run. Persists nothing, starts nothing, spends nothing.
      required:
        - scenario_id
        - question_set_id
        - question_set_version_id
        - scope_summary
        - lineages
        - bundle_count
        - routed_document_count
        - metering
      properties:
        scenario_id:
          type: string
          format: uuid
        question_set_id:
          type: string
          format: uuid
        question_set_version_id:
          type: string
          format: uuid
          description: The version a run started now would pin — the set's latest PUBLISHED version.
        scope_summary:
          $ref: "#/components/schemas/EvaluationScopeSummary"
          description: "The would-be counts + degrade verdict, the same object a started run reports. Unlike a run's, this one is computed on the fly and is therefore a FORECAST: launching later, after more uploads or a republish, legitimately produces a different plan."
        lineages:
          type: array
          items:
            $ref: "#/components/schemas/ReevaluationPlanLineage"
          description: Every lineage in the previewed effective set plus every prior lineage that left it, ordered by lineage id.
        bundle_count:
          type: integer
          description: Evidence bundles the run would assemble — the distinct routed document-sets across the re-answered lineages. The unit LLM cost scales with, which is why it is here rather than left to be inferred from the lineage count.
        routed_document_count:
          type: integer
          description: Distinct logical documents routed to the re-answered lineages.
        sweep_scope:
          type: array
          items:
            type: string
            format: uuid
          description: "The lineages the flag-only consistency sweep would cover — the closure plus doc-sharing neighbour expansion. Absent on a zero-closure preview: nothing is perturbed, so nothing is re-checked."
        metering:
          $ref: "#/components/schemas/ReevaluationMeteringPreview"
    EffectiveQuestionSetItemKind:
      type: string
      enum:
        - global
        - supplemental
        - exempted
        - modified
      description: The closed set of ways an item can enter a case's effective question set.
    EffectiveQuestionSetItem:
      type: object
      description: "One frozen effective-set entry with its per-item provenance. Field applicability depends on kind: global | exempted | modified carry question_id; supplemental carries scenario_question_id; exempted/modified/supplemental carry directive_document_id + citation_block_ids; exempted/modified also carry supersession_id + review_status (+ modification for modified)."
      required:
        - kind
        - lineage_id
        - criticality
      properties:
        kind:
          $ref: "#/components/schemas/EffectiveQuestionSetItemKind"
        lineage_id:
          type: string
          format: uuid
        question_text:
          type: string
          description: Display snippet (~120 chars) of the item's question text, resolved at read time — from the pinned version's question row (global/ exempted/modified) or the compiled scenario question (supplemental) — so no reader ever has to render a lineage id as a label (rule 3). Absent when the underlying row no longer resolves. Not part of the frozen effective-set document.
        criticality:
          type: string
          description: eligibility | documentation | data_integrity.
        question_id:
          type: string
          format: uuid
        scenario_question_id:
          type: string
          format: uuid
        supersession_id:
          type: string
          format: uuid
        review_status:
          $ref: "#/components/schemas/SupersessionReviewStatus"
        modification:
          type: object
          additionalProperties: true
          description: Node-addressed pass_when/tolerance patches (kind=modified only).
        directive_document_id:
          type: string
          format: uuid
        citation_block_ids:
          type: array
          items:
            type: string
            format: uuid
        flags:
          type: array
          items:
            type: string
          description: Advisory review markers on this item, such as an overlapping supersession. Empty when there are none.
    EffectiveQuestionSetPolicy:
      type: object
      required:
        - auto_apply_non_eligibility
      properties:
        auto_apply_non_eligibility:
          type: boolean
          description: "Whether a non-eligibility change may apply without a person confirming it. Always false today: every change is confirmed."
    EffectiveQuestionSet:
      type: object
      description: The frozen `effective-set/v1` projection, rendered directly — no reconstruction joins. A legacy (`{}`) evaluation is reconstructed into the identity projection.
      required:
        - evaluation_id
        - question_set_version_id
        - directive_extraction_ids
        - directives_pending
        - policy
        - items
        - stale
        - stale_reasons
      properties:
        evaluation_id:
          type: string
          format: uuid
        question_set_version_id:
          type: string
          format: uuid
        directive_extraction_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Pinned snapshots consumed.
        directives_pending:
          type: array
          items:
            type: string
            format: uuid
          description: Directive logical docs marked but with no completed extraction at run start (advisory).
        policy:
          $ref: "#/components/schemas/EffectiveQuestionSetPolicy"
        items:
          type: array
          items:
            $ref: "#/components/schemas/EffectiveQuestionSetItem"
        stale:
          type: boolean
          description: "The re-eval-needed marker: true when a directive artifact this evaluation pinned has since moved. The effective set itself is never mutated in place — see stale_reasons and EvaluateScenarioRequest.reevaluate_of."
        stale_reasons:
          type: array
          items:
            type: string
          description: Human-readable reasons (empty unless stale).
    PurposeCost:
      type: object
      required:
        - purpose
        - total_usd
        - call_count
      properties:
        purpose:
          type: string
        total_usd:
          type: number
          format: double
        call_count:
          type: integer
    ScenarioCost:
      type: object
      description: A per-case cost rollup over the model-call ledger.
      required:
        - total_usd
        - call_count
        - by_purpose
      properties:
        total_usd:
          type: number
          format: double
        call_count:
          type: integer
        by_purpose:
          type: array
          items:
            $ref: "#/components/schemas/PurposeCost"
        budget:
          $ref: "#/components/schemas/ScenarioBudget"
          description: Null when no cost budget is configured for the scenario.
        tenant_month:
          $ref: "#/components/schemas/TenantMonthBudget"
          description: The tenant's cost budget for the current UTC calendar month — the tenant-wide monthly cap that also gates this scenario's spend. Null when no tenant-month budget is configured / no run has reserved against it this month.
    TenantMonthBudget:
      type: object
      description: The tenant's per-UTC-calendar-month cost budget for the current month. A rolling monthly cap enforced by the same reserve/settle path as the scenario budget (made the scope real). state here is derived from the month row's own settled vs its limits; the durable hard-breach pause is surfaced on ScenarioBudget.state.
      required:
        - month
        - reserved_usd
        - settled_usd
        - state
      properties:
        month:
          type: string
          description: The UTC calendar month, YYYY-MM (e.g. 2026-07).
        soft_limit_usd:
          type: number
          format: double
          description: Null = no soft limit configured.
        hard_limit_usd:
          type: number
          format: double
          description: Null = no hard limit configured.
        reserved_usd:
          type: number
          format: double
        settled_usd:
          type: number
          format: double
        remaining_usd:
          type: number
          format: double
          description: hard_limit − settled − reserved. Null when no hard limit is configured.
        state:
          type: string
          description: ok = under both limits; soft_degraded = at/over the soft limit; paused_budget = at/over the hard limit for the month.
          enum:
            - ok
            - soft_degraded
            - paused_budget
    ScenarioBudget:
      type: object
      description: The case's cost-budget limits and where it currently stands against them.
      required:
        - reserved_usd
        - settled_usd
        - state
      properties:
        soft_limit_usd:
          type: number
          format: double
          description: Null = no soft limit configured.
        hard_limit_usd:
          type: number
          format: double
          description: Null = no hard limit configured.
        reserved_usd:
          type: number
          format: double
        settled_usd:
          type: number
          format: double
        state:
          type: string
          description: ok = under both limits; soft_degraded = at/over the soft limit (visibly degraded, still proceeding); paused_budget = the scenario's latest evaluation durably paused on a hard breach (resumable).
          enum:
            - ok
            - soft_degraded
            - paused_budget
    DecisionOutcome:
      type: string
      description: A decision's outcome. Extracted to a named schema (unchanged values) so the tenant-wide GET /v1/decisions `?outcome=` filter shares one type with `Decision.outcome` instead of minting a parallel enum.
      enum:
        - approve
        - refer
        - decline
    DecisionReviewState:
      type: string
      description: A decision's human-acceptance state as seen from a list read. `pending` is the absence of any `decision_reviews` row — an unaccepted proposal; `accepted`/`rejected` mirror the newest review's action. Distinct from `DecisionReview.action`, which has no `pending` member because a stored review always carries a verdict.
      enum:
        - pending
        - accepted
        - rejected
    Decision:
      type: object
      description: One deterministic decision rollup. A PROPOSAL until a recorded human acceptance — see latest_review.
      required:
        - id
        - scenario_id
        - evaluation_id
        - seq
        - outcome
        - rationale
        - conditions
        - created_at
      properties:
        id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        evaluation_id:
          type: string
          format: uuid
        seq:
          type: integer
        outcome:
          $ref: "#/components/schemas/DecisionOutcome"
        rationale:
          type: array
          items:
            $ref: "#/components/schemas/DecisionRationale"
          description: Reason-coded, per-reason-cited, deterministically-templated rollup trace — never LLM-composed.
        conditions:
          type: array
          items:
            $ref: "#/components/schemas/DecisionCondition"
        answers_evaluated:
          type: integer
          minimum: 0
          description: |-
            How many answers this decision was actually computed over — the size of the answer set the rollup froze on the row at computation time (this run's current answers whose lineage is in its frozen effective question set; the same filter decision.Roll applied). Read straight off the stored decision, so every surface that renders a decision — the run view's card, the tenant-wide review queue, a future report — reads ONE number and cannot disagree about what the decision stands on.
            0 is a real, load-bearing value, not an edge case: a completed run whose documents cover none of the rulebook produces a decision with an empty rationale, which rolls up to `approve`. A surface that leads with that outcome tells a reviewer "no adverse findings" about a run that found nothing at all, so a surface MUST lead with this fact instead and must not present sign-off as a routine confirmation.
            OMITTED — never 0 — when the stored answer set carries no readable membership at all (a malformed or pre-snapshot row): a client has to be able to tell "evaluated nothing" from "not known", and a silent 0 would turn the second into the first.
        narrative_md:
          type: string
          description: LLM-written, cosmetic, cites answer IDs — no authority.
        created_at:
          type: string
          format: date-time
        latest_review:
          $ref: "#/components/schemas/DecisionReview"
    DecisionRationale:
      type: object
      required:
        - reason_code
        - question_lineage_id
        - answer_id
        - criticality
        - statement
        - citations
      properties:
        reason_code:
          type: string
          enum:
            - eligibility_fail
            - insufficient_evidence
            - documentation_fail
            - data_integrity_fail
            - directive_condition
        question_lineage_id:
          type: string
          format: uuid
        answer_id:
          type: string
          format: uuid
        criticality:
          type: string
          description: eligibility | documentation | data_integrity. Plain string (not a closed enum) for stored-row fidelity.
        statement:
          type: string
          description: "Deterministically templated from the pinned question text — never LLM-composed. CUSTOMER-CLEAN (S8 wave 2, C2): plain prose with the question text's inline markdown resolved away, and NEVER carrying the evaluator's logic bindings — pre-C2 stored rows that embedded a trailing \"(answered: n1=…)\" fragment are normalized at read time; the fragment moves to `bindings`."
        bindings:
          type: string
          description: "PLATFORM-ONLY (internal operators). The evaluator's answered-value projection behind this reason (e.g. \"n1=true, n2=0\") — logic-node bindings, forensic detail, not customer prose. ABSENT for every non-platform principal, exactly like ConsensusRecord's model-identity triple: a tenant's own admin is the customer-facing ceiling and sits BELOW this boundary. Optional (not required) precisely so it can be OMITTED rather than blanked — \"\" would be a false claim of empty bindings, and clients must be able to tell \"not disclosed to you\" from \"no value\". Also absent (for every principal) when the driving answer recorded no value projection."
        citations:
          type: array
          items:
            $ref: "#/components/schemas/DecisionCitation"
          description: The citations of the answer that drove this reason. Stored rows may hold JSON null (an uncited answer); the API normalizes that to [].
      description: "One rationale entry: a reason code, its cited evidence, and a deterministically templated sentence."
    DecisionCitation:
      type: object
      description: One evidence pointer supporting a rationale — a layout block id, or a path to a structured fact.
      required:
        - kind
        - ref
      properties:
        kind:
          type: string
          enum:
            - block
            - fact
        ref:
          type: string
          description: Block UUID (kind=block) or extraction fact path (kind=fact).
    DecisionCondition:
      type: object
      description: One auto-generated condition — a missing-evidence item naming what must be supplied to resolve an abstention.
      required:
        - kind
        - question_lineage_id
        - statement
      properties:
        kind:
          type: string
          enum:
            - missing_evidence
        question_lineage_id:
          type: string
          format: uuid
        statement:
          type: string
        doc_types:
          type: array
          items:
            type: string
          description: Absent when the question's evidence requirement names no doc types.
        fact_paths:
          type: array
          items:
            type: string
          description: Absent when the question's evidence requirement names no fact paths.
    DecisionList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Decision"
    CurrentDecision:
      type: object
      description: One scenario's CURRENT (highest-seq) decision together with the scenario it belongs to — the tenant-wide review-queue row. Both members are the same shapes the single-resource reads return (no abbreviated list variants).
      required:
        - scenario
        - decision
      properties:
        scenario:
          $ref: "#/components/schemas/Scenario"
        decision:
          $ref: "#/components/schemas/Decision"
    CurrentDecisionList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/CurrentDecision"
        next_cursor:
          type: string
          description: Opaque pagination cursor over `scenario_id`; absent on the last page.
    DecisionReview:
      type: object
      description: The mandatory human sign-off — append-only; a decision may be reviewed more than once.
      required:
        - id
        - decision_id
        - action
        - created_at
      properties:
        id:
          type: string
          format: uuid
        decision_id:
          type: string
          format: uuid
        action:
          type: string
          enum:
            - accepted
            - rejected
        note_md:
          type: string
        created_at:
          type: string
          format: date-time
    ReviewDecisionRequest:
      type: object
      required:
        - action
      properties:
        action:
          type: string
          enum:
            - accepted
            - rejected
        note_md:
          type: string
    SupersessionReviewStatus:
      type: string
      description: A supersession's human-review state — pending | confirmed | rejected. Values validated server-side (the client never writes review_status directly; the query filter is checked on read).
    ReviewSupersessionRequest:
      type: object
      description: A human's confirm/reject decision on a supersession.
      required:
        - action
      properties:
        action:
          type: string
          description: confirm | reject (validated server-side).
        note:
          type: string
          description: Rationale — REQUIRED when rejecting.
    QuestionSupersession:
      type: object
      description: One directive-derived exemption or modification of a GLOBAL question lineage. Born pending; effective only once confirmed (eligibility targets always require confirmation).
      required:
        - id
        - scenario_id
        - directive_extraction_id
        - directive_document_id
        - target_question_lineage_id
        - target_criticality
        - action
        - rationale
        - citation_block_ids
        - matched_by
        - review_status
        - created_at
      properties:
        id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        directive_extraction_id:
          type: string
          format: uuid
        directive_document_id:
          type: string
          format: uuid
        target_question_lineage_id:
          type: string
          format: uuid
        target_criticality:
          type: string
          description: eligibility | documentation | data_integrity — display metadata; the compute rule re-resolves against the pinned version.
        action:
          type: string
          description: exempt | modify.
        modification:
          type: object
          nullable: true
          additionalProperties: true
          description: Node-addressed pass_when/tolerance patches (modify only; null for exempt).
        rationale:
          type: string
        citation_block_ids:
          type: array
          items:
            type: string
            format: uuid
        matched_by:
          type: string
          description: model_agreement | human.
        match_confidence:
          type: number
          nullable: true
          description: Raw, uncalibrated agreement confidence; null for human-created supersessions.
        review_status:
          $ref: "#/components/schemas/SupersessionReviewStatus"
        reviewed_by:
          type: string
          format: uuid
          nullable: true
        reviewed_by_display_name:
          type: string
          description: The reviewer's human name (display name, falling back to email), resolved at read time by joining users — a reviewer reads "Confirmed by Dana Ruiz", never a UUID (S8 wave2 C1). Absent while the supersession is unreviewed, and when the user no longer resolves; callers fall back to the `reviewed_by` id. Never stored on the row, so the audit chain is untouched.
        reviewed_at:
          type: string
          format: date-time
          nullable: true
        review_note:
          type: string
          nullable: true
        target_question_text:
          type: string
          description: Display snippet (~120 chars) of the targeted GLOBAL question's current text, resolved at read time (latest question row for `target_question_lineage_id`) so a review card can name what is being waived or changed instead of showing a lineage id. Absent when the lineage no longer resolves. Never stored on the row.
        created_at:
          type: string
          format: date-time
        affected_evaluation_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Completed evaluations whose frozen effective_question_set pinned this supersession's extraction — this review may have changed what should be effective for them (never mutated in place; a re-eval-needed offer). Present only on the review response (POST.../review); absent when listing.
    QuestionSupersessionList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/QuestionSupersession"
    DirectiveDocumentCoverage:
      type: object
      description: A rollup of the latest extraction's coverage-ledger rows — present only once at least one extraction has run for the document.
      required:
        - ledger_rows
        - supplemental_questions
        - supplemental_questions_compiled
        - facts
        - supersession_candidates
        - supersessions_matched
        - supersessions_flagged_unmatched
        - supersessions_dismissed
        - non_normative
      properties:
        ledger_rows:
          type: integer
          description: Total dispositioned blocks — the completeness proof's row count.
        supplemental_questions:
          type: integer
          description: SOURCE PASSAGES the extractor read as adding a question — ledger rows dispositioned `supplemental_question`, never a count of questions. It has always meant this; say so, because a client that labelled it "questions added" was wrong by a factor a real document produced live (57 passages, 40 questions). Use `supplemental_questions_compiled` for the question count.
        supplemental_questions_compiled:
          type: integer
          description: "How many DISTINCT questions those passages actually became — the `scenario_questions` rows this extraction compiled, which is exactly what the case's effective set gains. One question may cover several statements and therefore several passages, so this is normally the smaller figure, and it is the only honest answer to \"how many questions did this document add\". It is NOT derivable from the ledger: a statement that is no block's primary link leaves its question with no `compiled_ref` pointing at it, so counting `compiled_ref` values understates just as counting rows overstates. Zero for a run that has not compiled yet."
        facts:
          type: integer
        supersession_candidates:
          type: integer
          description: Blocks dispositioned as a rule-change request. Always equal to supersessions_matched + supersessions_flagged_unmatched + supersessions_dismissed — the arithmetic is complete by construction, so a client can always account for every candidate.
        supersessions_matched:
          type: integer
          description: Rule-change requests that became a question_supersessions row — either the automated 2-model agreement or a human's manual add.
        supersessions_flagged_unmatched:
          type: integer
          description: Rule-change requests with no question_supersessions row covering their source block — the automated matcher could not resolve them (zero candidates, model disagreement, or a seat failure) and a human has not yet manually created one either. Needs manual review — dismissed entries are NOT counted here.
        supersessions_dismissed:
          type: integer
          description: "Rule-change requests a human read and judged not to be a rule change at all, so they are no longer work. Counted at the source-block grain: a block counts as dismissed only when every flagged statement citing it has been dismissed."
        non_normative:
          type: integer
    DirectiveExtractionProgress:
      type: object
      description: |-
        A RUNNING directive extraction's live progress. Exists because the extract stage can run for tens of minutes as one activity, and a bare `running` status is indistinguishable from a stuck one.
        units_done/units_total follow the ProgressStage contract: a total is ABSENT unless the stage has a real plan, and a client renders an indeterminate bar rather than fabricating a denominator. In practice only `extract` carries both — its total is the window count, its done count the durably checkpointed windows — and the other stages are stage-name-only. Both counts are monotonically non-decreasing across a resumed activity.
        started_at is when the CURRENT stage first began (its first attempt, not a retry's), so a client can compute its own pace/ETA without the server promising one.
      required:
        - stage
        - started_at
      properties:
        stage:
          type: string
          description: "extract | compile | match | complete — the Pipeline C stage sequence, in order. `extract` is the long one: a dozen-plus sequential LLM calls over the directive's passages, and the only stage that counts units. Deliberately an open string, like `latest_extraction_status` beside it — a client renders an unrecognised stage as plain \"processing\", never an error."
        units_done:
          type: integer
        units_total:
          type: integer
        started_at:
          type: string
          format: date-time
    DirectiveDocumentSummary:
      type: object
      description: One scenario directive document (a logical_documents row with is_directive=true) and its latest extraction.
      required:
        - directive_document_id
      properties:
        directive_document_id:
          type: string
          format: uuid
        latest_extraction_id:
          type: string
          format: uuid
        latest_extraction_status:
          type: string
          description: running | completed | failed. Absent when no extraction has ever started for this document (e.g. classification still pending human confirmation).
        latest_extraction_seq:
          type: integer
        extraction_progress:
          $ref: "#/components/schemas/DirectiveExtractionProgress"
          description: What the latest extraction is doing right now. Present ONLY while `latest_extraction_status` is `running`, and only once the run's first stage has stamped — absent is a normal shape (a terminal run, a run whose first activity has not reached its stamp, a run that started before this field existed), and a client renders the plain status instead rather than treating it as an error.
        previous_extraction_id:
          type: string
          format: uuid
          description: The latest extraction's supersedes_extraction_id — present exactly when a re-derive diff is available via GET /scenarios/{scenarioId}/directives/{directiveDocumentId}/diff.
        coverage:
          $ref: "#/components/schemas/DirectiveDocumentCoverage"
        flagged_unmatched_block_ids:
          type: array
          items:
            type: string
            format: uuid
          description: The source blocks behind each flagged-unmatched rule-change request (for citation click-through into the directive document). Blocks whose every flagged statement has been DISMISSED are absent unless `includeDismissed=true` — the default listing is the work queue, and a dismissed entry is not work. Their count is still reported as `coverage.supersessions_dismissed`.
        flagged_unmatched_reasons:
          type: array
          items:
            $ref: "#/components/schemas/DirectiveStatementFlag"
          description: "WHY each still-unresolved rule-change request was left for a human, and WHAT it said — the LATEST extraction only, and only for blocks still in `flagged_unmatched_block_ids`: a flagged block that has since gained a manual supersession is resolved and is NOT reported here. DISMISSED entries are excluded unless `includeDismissed=true`, and an entry is filtered individually: a block cited by two statements, one dismissed, still reports the other."
    DirectiveStatementFlagReason:
      type: string
      description: "Why the automated matcher left one directive statement for a human. no_candidates: no plausible standard question was found at all, so no AI review ran. insufficient_families: fewer than two independent model families were available, so no agreement could be formed. seat_output_invalid: an automated review returned an unusable result. seat_transport_error: an automated review could not be completed. model_disagreement: the two automated reviews did not agree. invalid_modification: the agreed change did not fit the target question's logic."
      enum:
        - no_candidates
        - insufficient_families
        - seat_output_invalid
        - seat_transport_error
        - model_disagreement
        - invalid_modification
    DirectiveStatementFlag:
      type: object
      description: "One durable per-statement flag reason at the grain of (statement × cited block), mirroring the coverage ledger's own per-block grain. The BODY is append-only: it records what the matcher reported at match time and is never revised — resolution is derived from a supersession row later citing the block. The only mutable state is the dismissal."
      required:
        - id
        - statement_ref
        - block_id
        - reason
        - attempt
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: The flag row's own id — the handle for POST /directive-statement-flags/{flagId}/dismissal.
        statement_ref:
          type: string
          description: The match stage's per-statement key — stable across a resumed run.
        block_id:
          type: string
          format: uuid
        reason:
          $ref: "#/components/schemas/DirectiveStatementFlagReason"
        attempt:
          type: integer
          description: "Which analysis of this (statement, block) produced this row: 1 for the extraction's own match, higher for a reviewer-triggered re-analysis (POST /directive-statement-flags/{flagId}/rematch). The review listing reports only the LATEST attempt per (statement_ref, block_id) — a superseded attempt is history, not work — while the per-run extraction read returns every attempt, because \"the first analysis timed out and the second disagreed\" is what an operator needs to see."
        topic:
          type: string
          description: The extractor's own short category label for the statement — a classification, never a quotation. Useful as a chip beside the statement text.
        normative_text:
          type: string
          description: "WHAT the statement said — the plain-language rule read out of the document, in the extraction's own words. This is the line a reviewer reads to decide whether the document changes a rule at all. Absent for a run old enough that the text was not stored, and never reconstructed: an absence is honest where a blank sentence would not be. `source_text` below is the fallback, and it is available for every run."
        source_label:
          type: string
          description: "WHERE in a structured document the passage came from, as a person reads it — the caption to render ABOVE `source_text`, never inside the quote. It is the display name supplied by the adapter that parsed the document (\"Underwriting finding\"), falling back to the humanized element name when that is descriptive on its own (`lpa:AutomatedUnderwritingSystemMessageDescription` becomes \"Automated Underwriting System Message Description\"). Derived at read time from the block's own stored text, exactly like `source_text` (nothing is copied into the flag row, and no stored text is rewritten). ABSENT for a block that is not an element fragment — a document page's prose paragraph, a table cell, a bare projected value — and also for an element name that names nothing a reader could use (\"Message\", \"Line\"): a clean quotation with no caption beats a caption that means nothing. Render the quotation uncaptioned whenever it is absent."
        source_text:
          type: string
          description: The cited block's own parsed text as a reviewer should READ it, resolved from layout_blocks at READ time (never copied into the flag row), so it follows a reparse, an accepted reading correction and an erasure (an erased document's block reads as the tombstone marker, never as nothing). Absent when the block did not resolve. A structured document's block is one element's own source bytes, so what is STORED there carries the element's markup; this field is the PRESENTATION of it — the element's text content with tags, entity references and CDATA wrappers resolved away and whitespace collapsed (see `source_label` for the element's name). A prose block passes through unchanged. Capped at 2000 characters, never cut mid-word; see `source_text_truncated`.
        source_text_truncated:
          type: boolean
          description: "True when the passage continues past `source_text`. Two causes, and a client needs no distinction between them to render the ellipsis that says so: the text met the 2000-character serve cap, or the stored block text was ITSELF already cut — a structured file's leaf snippet is capped when the file is parsed, so a long text element reaches storage incomplete and no read path can put those bytes back. In that second case the dangling partial word is dropped at the last word boundary rather than shown. Either way the citation's viewer deep-link reaches the whole passage in the document."
        dismissed_at:
          type: string
          format: date-time
          description: When a human judged this statement not to be a rule change at all. Present only on a dismissed flag. A dismissed flag is excluded from the default `flagged_unmatched_*` projection and from the case attention line, and is returned by GET /scenarios/{scenarioId}/directives?includeDismissed=true and by the per-run extraction read. The dismissal is REVERSIBLE (POST the same endpoint with action=restore).
        dismissed_by:
          type: string
          format: uuid
          description: The user who dismissed it. Present only on a dismissed flag.
        dismissed_by_display_name:
          type: string
          description: That user's name, resolved at read time so no UUID ever renders as a name. Absent when nothing resolved (no dismissal, or the user row is gone) — fall back to `dismissed_by`.
        dismiss_rationale:
          type: string
          description: The dismisser's own words, optional and at most 2000 characters. The human's own text about their own decision, never document content.
        detail:
          type: object
          additionalProperties: true
          description: "Redacted specifics behind the reason (the two reviews' verdicts for a disagreement, the failing family plus a classified error summary for a seat failure, the families available for insufficient_families). Never raw provider payloads and never document content. IDENTITY-REDACTED for non-platform principals: `family`, `model` and `available_families` are removed, and the whole key is omitted if nothing else remains. The classified error summary and the verdicts survive."
        created_at:
          type: string
          format: date-time
    DirectiveStatementFlagDismissalRequest:
      type: object
      description: One dismiss/restore of a flagged directive statement.
      required:
        - action
      properties:
        action:
          type: string
          description: "dismiss: this statement is not a rule change; take it off the queue. restore: put it back on the queue."
          enum:
            - dismiss
            - restore
        rationale:
          type: string
          maxLength: 2000
          description: The dismisser's own words, optional. Unlike a supersession REJECTION — which rejects a rule change two models agreed on, and is therefore a calibration-grade label the contract requires — a dismissal usually says only "the extractor over-read a sentence", so forcing prose would buy noise. Ignored by `restore`, which clears the stored rationale along with the dismissal it explained.
    DirectiveStatementFlagRematchResult:
      type: object
      description: The outcome of re-running the automated match for one flagged directive statement.
      required:
        - outcome
        - attempt
      properties:
        outcome:
          type: string
          description: "`matched`: the two readings agreed, and `supersession_id` names a new pending rule change for a person to confirm or reject — this call never confirms one. `flagged`: still unresolved, with `reason` recorded against this attempt."
          enum:
            - matched
            - flagged
        attempt:
          type: integer
          description: For `flagged`, the attempt number this re-analysis recorded. For `matched`, the attempt that was current when it ran — nothing new is written to the flag, because a flagged statement is resolved by a supersession citing its block, never by a stamp on the flag.
        reason:
          $ref: "#/components/schemas/DirectiveStatementFlagReason"
        supersession_id:
          type: string
          format: uuid
          description: 'The newly created PENDING supersession (`outcome: "matched"` only). Review it with POST /supersessions/{id}/review.'
        flags:
          type: array
          items:
            $ref: "#/components/schemas/DirectiveStatementFlag"
          description: "The new attempt's rows, one per block the statement cites (`outcome: \"flagged\"` only). These are what the review listing will report from now on; the earlier attempts stay readable through the per-run extraction read."
    DirectiveDocumentList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/DirectiveDocumentSummary"
    DirectiveExtractionRun:
      type: object
      description: One directive extraction run — the run row itself, without its artifacts.
      required:
        - id
        - scenario_id
        - directive_document_id
        - document_id
        - seq
        - status
        - created_at
      properties:
        id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        directive_document_id:
          type: string
          format: uuid
        document_id:
          type: string
          format: uuid
          description: The parse (document) whose layout blocks this run's citations point into.
        seq:
          type: integer
          description: Monotonic run number for this directive document.
        supersedes_extraction_id:
          type: string
          format: uuid
          nullable: true
          description: The run this one re-derives over, when it is a re-derive.
        status:
          type: string
          description: running | completed | failed.
        extraction_meta:
          type: object
          additionalProperties: true
          description: Prompt versions, model keys, window count — run provenance, not content.
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
    DirectiveLedgerRow:
      type: object
      description: "One coverage-ledger row: the disposition one model gave one layout block — the run's exhaustiveness proof, one row per (block × model)."
      required:
        - id
        - block_id
        - disposition
        - created_at
      properties:
        id:
          type: string
          format: uuid
        block_id:
          type: string
          format: uuid
        model:
          type: string
          description: "PLATFORM-ONLY (internal operators). The registry model key that produced this row. Optional (not required) so the field can be OMITTED rather than blanked to \"\". The row's exhaustiveness proof does not depend on it: `id`/`block_id`/ `disposition` still account for every block."
        disposition:
          type: string
          description: supplemental_question | supersession_candidate | fact | non_normative.
        reason:
          type: string
          nullable: true
          description: Required alongside non_normative — why the block compiles to nothing.
        compiled_ref:
          type: string
          format: uuid
          nullable: true
          description: What the block compiled to (a supplemental question, a stated fact). Always null for supersession_candidate — a matched supersession links back by citation, never by back-filling the ledger.
        created_at:
          type: string
          format: date-time
    DirectiveSourcePassage:
      type: object
      description: "One CITED block as a reviewer reads it: the block id the citation names, plus the same read-time presentation a flagged statement's `source_text`/`source_label` pair carries. Resolved from `layout_blocks` on every read and never copied into the citing row, so it follows a reparse, an accepted reading correction and an erasure (an erased document's block reads as the tombstone marker, never as nothing)."
      required:
        - block_id
      properties:
        block_id:
          type: string
          format: uuid
          description: The cited block — the same currency the evidence viewer navigates by, so a passage's own "view it in the document" link lands on THIS passage rather than on the citation's first block.
        source_label:
          type: string
          description: WHERE in a structured document the passage came from, as a person reads it — the caption to render ABOVE `source_text`, never inside the quote. It is the display name supplied by the adapter that parsed the document ("Underwriting finding"), falling back to the humanized element name when that is descriptive on its own (`lpa:AutomatedUnderwritingSystemMessageDescription` becomes "Automated Underwriting System Message Description"). ABSENT for a block that is not an element fragment (a page's prose paragraph, a table cell, a bare projected value), and for an element name that names nothing a reader could use ("Message", "Line") — render the quotation uncaptioned rather than substituting anything.
        source_text:
          type: string
          description: "What to quote: the block's text with any markup, entity references and CDATA wrappers resolved away, capped at a generous serve bound on a word boundary. An entry is OMITTED from `source_passages` entirely rather than carrying an empty quotation, so this is present whenever its entry is."
        source_text_truncated:
          type: boolean
          description: 'The passage continues past `source_text` — it met the serve cap, or the stored block text was itself already cut at parse time. Absent or false means the quotation is the whole passage. Render the difference: an ellipsis and the document link are what say "there is more of this".'
    ScenarioQuestionSummary:
      type: object
      description: One compiled supplemental question this run added for this case — the review-facing summary, without the logic AST or derived answer schema.
      required:
        - id
        - lineage_id
        - ordinal
        - text_md
        - criticality
        - citation_block_ids
        - created_at
      properties:
        id:
          type: string
          format: uuid
        lineage_id:
          type: string
          format: uuid
          description: Stable across re-derives; answers key on it, not on id.
        ordinal:
          type: integer
        text_md:
          type: string
        criticality:
          type: string
          description: eligibility | documentation | data_integrity.
        citation_block_ids:
          type: array
          items:
            type: string
            format: uuid
        source_passages:
          type: array
          items:
            $ref: "#/components/schemas/DirectiveSourcePassage"
          description: "The words of every cited block that still resolves, in citation order — so a reviewer can read what a question was derived FROM without leaving the list. Resolved from `layout_blocks` on every read, never copied into the question row. Optional on the wire, and a SHORTER list than `citation_block_ids` is a normal shape, for two reasons: a citation into an erased document or into a parse this read can no longer see is omitted rather than served as an empty quotation, and a passage whose words another passage already carries — a structured document's element and the row nested inside it commonly derive the same sentence — is served ONCE, keeping the containing block's id so its document link still lands on the whole passage. Absent entirely from a server older than this field."
        evidence_requirements:
          $ref: "#/components/schemas/EvidenceRequirements"
          description: "What this question is answered FROM — the same routing shape a global question carries. Optional on the wire for the same reason `model` is on a ledger row: a stored row always has it, but a reader must tolerate its absence rather than assume. Present so a reviewer reading a directive's added questions before any run can see which documents each one will be matched to — an empty `doc_types` AND `fact_paths` means the question routes to nothing and will evaluate nothing, which is precisely what the reviewer needs to notice."
        consistency_flags:
          type: array
          items:
            type: object
            additionalProperties: true
          description: "Compile-time text-vs-logic mismatches the consistency check raised — empty when the check agreed. Each entry carries `kind` (`text_logic_mismatch` or `absence_dependent_logic`), a reviewer- facing `explanation`, and, for a lint hit, `authoring_findings`: AuthoringFinding objects. Untyped here deliberately — the payload is a stored jsonb blob whose AI-identity keys are redacted per caller, so a client parses it tolerantly rather than binding to a fixed shape."
        created_at:
          type: string
          format: date-time
    DirectiveExtractionDetail:
      type: object
      description: "One directive extraction run in full: the run row, its coverage rollup, every coverage-ledger row, every durable per-statement flag reason, the supplemental questions it compiled, and the supersessions it produced. Member-read, the same tier as the directive listing this drills into."
      required:
        - extraction
        - coverage
        - ledger
        - flags
        - supplemental_questions
        - supersessions
      properties:
        extraction:
          $ref: "#/components/schemas/DirectiveExtractionRun"
        coverage:
          $ref: "#/components/schemas/DirectiveDocumentCoverage"
        ledger:
          type: array
          items:
            $ref: "#/components/schemas/DirectiveLedgerRow"
        flags:
          type: array
          items:
            $ref: "#/components/schemas/DirectiveStatementFlag"
        supplemental_questions:
          type: array
          items:
            $ref: "#/components/schemas/ScenarioQuestionSummary"
        supersessions:
          type: array
          items:
            $ref: "#/components/schemas/QuestionSupersession"
    CreateSupersessionRequest:
      type: object
      description: "A human creating a supersession directly from a flagged unmatched statement (manual escape hatch) — for when the two automated reviews could not agree but a person can read the directive plainly. Born `pending`; confirming it is a SECOND, separate action, so the create and the confirm stay distinct audit events. Every cited block must currently be a flagged-unmatched rule-change request of the named extraction: the escape hatch can only resolve a request the document actually made, never manufacture one."
      required:
        - directive_extraction_id
        - citation_block_ids
        - target_question_lineage_id
        - action
        - rationale
      properties:
        directive_extraction_id:
          type: string
          format: uuid
          description: The extraction run whose flagged statement this resolves.
        citation_block_ids:
          type: array
          minItems: 1
          items:
            type: string
            format: uuid
          description: The source blocks this rule change rests on, carried from the flagged statement. Each must be a rule-change request of this extraction that no supersession covers yet (422 / 409 otherwise).
        target_question_lineage_id:
          type: string
          format: uuid
          description: The GLOBAL question lineage to waive or change. A directive may not supersede another directive's supplemental question, and the target's criticality is resolved server-side from the pinned question set — never taken from the request.
        action:
          type: string
          enum:
            - exempt
            - modify
          description: exempt waives the question; modify patches its logic.
        modification:
          type: object
          additionalProperties: true
          nullable: true
          description: Node-addressed pass_when/tolerance patches — REQUIRED when action=modify, and rejected when action=exempt. The patched logic must re-validate server-side.
        rationale:
          type: string
          description: The human's own reason, in their words. Required — it is the calibration label for this decision.
    DirectiveDiffSupplementalChangeKind:
      type: string
      description: A supplemental scenario question's classification between two extractions, matched by its carried-forward lineage_id. "changed" = same lineage, different text_md/logic.
      enum:
        - added
        - removed
        - changed
        - unchanged
    DirectiveDiffSupplementalItem:
      type: object
      required:
        - change_kind
        - lineage_id
      properties:
        change_kind:
          $ref: "#/components/schemas/DirectiveDiffSupplementalChangeKind"
        lineage_id:
          type: string
          format: uuid
        old_scenario_question_id:
          type: string
          format: uuid
          description: Absent for change_kind=added.
        new_scenario_question_id:
          type: string
          format: uuid
          description: Absent for change_kind=removed.
        old_text_md:
          type: string
        new_text_md:
          type: string
    DirectiveDiffSupersessionChangeKind:
      type: string
      description: One target lineage's appearance/disappearance between two extractions' supersession rows — matching runs fresh every re-derive, so there is no "changed" category the way supplementals have.
      enum:
        - appeared
        - vanished
    DirectiveDiffSupersessionItem:
      type: object
      required:
        - change_kind
        - target_lineage_id
        - flagged_source_vanished
      properties:
        change_kind:
          $ref: "#/components/schemas/DirectiveDiffSupersessionChangeKind"
        target_lineage_id:
          type: string
          format: uuid
        target_question_text:
          type: string
          description: Display snippet (~120 chars) of the targeted GLOBAL question's current text, resolved at read time (latest question row for the lineage) so the diff view names the rule being changed instead of rendering a lineage id (rule 3). Absent when the lineage no longer resolves. Not part of the audited completion-time diff computation.
        old_supersession_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Set for change_kind=vanished.
        new_supersession_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Set for change_kind=appeared.
        flagged_source_vanished:
          type: boolean
          description: True only for change_kind=vanished when at least one of the OLD extraction's rows for this lineage was review_status=confirmed — flagged, never silently dropped; the confirmed row stays immutable history, a human resolves the flag.
    DirectiveDiff:
      type: object
      description: Re-derive diff — the directive document's latest completed extraction vs the run it supersedes. Mirrors the guideline re-upload diff API shape.
      required:
        - directive_document_id
        - scenario_id
        - new_extraction_id
        - supplementals
        - unchanged_supplemental_count
        - supersessions
        - affected_evaluation_ids
      properties:
        directive_document_id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
        old_extraction_id:
          type: string
          format: uuid
          description: Absent for a root extraction (nothing to diff against).
        new_extraction_id:
          type: string
          format: uuid
        supplementals:
          type: array
          items:
            $ref: "#/components/schemas/DirectiveDiffSupplementalItem"
          description: added/removed/changed only — unchanged lineages are counted, not listed.
        unchanged_supplemental_count:
          type: integer
        supersessions:
          type: array
          items:
            $ref: "#/components/schemas/DirectiveDiffSupersessionItem"
        affected_evaluation_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Completed evaluations whose frozen effective_question_set pinned the OLD extraction — this re-derive makes them stale.
    OverrideAnswerRequest:
      type: object
      description: A reviewer's answer-verdict override. reason is mandatory.
      required:
        - result
        - reason
      properties:
        result:
          type: string
          enum:
            - pass
            - fail
            - not_applicable
            - insufficient_evidence
          description: The reviewer's new verdict for the answer.
        reason:
          type: string
          minLength: 1
          description: MANDATORY justification — an override without a recorded reason is not permitted.
        value:
          description: Optional replacement for the answer-level value projection; omitted carries the prior answer's value forward.
    OverrideAnswerResult:
      type: object
      description: What an override produced — the new current answer, the answer it superseded, the recomputed decision PROPOSAL (fresh acceptance required), and the candidate calibration-label id.
      required:
        - answer
        - superseded_answer_id
        - decision
        - candidate_label_id
      properties:
        answer:
          $ref: "#/components/schemas/Answer"
        superseded_answer_id:
          type: string
          format: uuid
        decision:
          $ref: "#/components/schemas/Decision"
        candidate_label_id:
          type: string
          format: uuid
    FileType:
      type: object
      description: One entry in the registry of file types this deployment can read.
      required:
        - key
        - family
        - status
        - content_types
        - extensions
        - max_size_bytes
        - docs_md
        - synced_at
      properties:
        key:
          type: string
        family:
          type: string
          enum:
            - layout
            - structured
        status:
          type: string
          enum:
            - supported
            - scheduled
            - retired
        content_types:
          type: array
          items:
            type: string
        extensions:
          type: array
          items:
            type: string
        max_size_bytes:
          type: integer
          format: int64
        capabilities:
          type: object
          additionalProperties: true
        docs_md:
          type: string
          description: Roadmap/status copy rendered by the upload UI.
        adapter_version:
          type: string
        status_session:
          type: string
          description: The session that landed (or, for scheduled, will land) this entry — data only, never gates behavior.
        synced_at:
          type: string
          format: date-time
    FileTypeList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/FileType"
    CanonicalKeyEntry:
      type: object
      description: One canonical structured-facts key vocabulary entry, mirrored from the embedded vocabulary pack.
      required:
        - key
        - display_name
        - value_type
        - unit
        - enum_values
        - description
        - status
        - synced_at
      properties:
        key:
          type: string
          description: The machine value stored on a question's `evidence_requirements.fact_paths` and joined against a fact's `canonical_key`. A dotted identifier, never shown to a person — use `display_name` for that.
        display_name:
          type: string
          minLength: 1
          description: What to show a person for this key — the label the fact_paths picker renders instead of the dotted `key`. Authored per key in the vocabulary pack, never derived from `key` (a good label is not always the key title-cased). Always present.
        vertical:
          type: string
          description: The line of business this key belongs to, the same machine value a case, a question set and a tenant's default binding carry. ABSENT for the shipped default vertical — absent is how this whole contract spells "the default" (see `Vertical.key`), so an absent `vertical` here matches a stored value carrying none. Present only on an additional vertical's keys. When you scope the list with `question_set_id` every entry shares the set's vertical; it is on the entry for the unscoped lookup, where more than one vertical's keys are returned together.
          pattern: ^[a-z][a-z0-9_]*$
          example: contract_review
        value_type:
          type: string
          enum:
            - string
            - boolean
            - integer
            - decimal
            - date
            - enum
            - ref
        unit:
          type: string
          description: Empty string when the key has no unit (e.g. non-decimal keys, or a decimal with no natural unit).
        enum_values:
          type: array
          items:
            type: string
        description:
          type: string
        status:
          type: string
          enum:
            - active
            - deprecated
        synced_at:
          type: string
          format: date-time
    CanonicalKeyList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/CanonicalKeyEntry"
    Vertical:
      type: object
      description: One kind of underwriting this platform offers, and the label to show a person for it.
      required:
        - display_name
      properties:
        key:
          type: string
          description: "The value stored on the work — a case's `vertical`, a question set's, a tenant's default binding. ABSENT on exactly one entry in the catalog: the shipped default, which is what anything carrying no `vertical` is on. So an absent `key` here matches an absent `vertical` there, and comparing the two directly is correct without a special case. Machine value, never display text."
          pattern: ^[a-z][a-z0-9_]*$
          example: contract_review
        display_name:
          type: string
          minLength: 1
          description: What to show a person. Always present, including for the default entry, so there is never a row a client has to invent a label for. Do not derive it from `key` when it is missing — it is not missing.
          example: Contract Review
    VerticalList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Vertical"
    QuestionGroup:
      type: object
      required:
        - id
        - name
        - created_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
        created_at:
          type: string
          format: date-time
      description: The organizational container for a workspace's guidelines, question sets and selection rules.
    CreateQuestionGroupRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
        description:
          type: string
    QuestionGroupList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/QuestionGroup"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    Guideline:
      type: object
      description: One uploaded guideline file version. Its layout_blocks (once parsed) are the citation targets for generated criteria and questions.
      required:
        - id
        - question_group_id
        - filename
        - display_name
        - content_type
        - size_bytes
        - sha256
        - parse_status
        - created_at
      properties:
        id:
          type: string
          format: uuid
        question_group_id:
          type: string
          format: uuid
        filename:
          type: string
          description: The uploaded file's original name — file metadata, not the guideline's identity.
        display_name:
          type: string
          minLength: 1
          maxLength: 200
          description: The guideline's human name — what every list/detail surface titles it. Defaults to a cleaned form of the uploaded filename; editable via PATCH.
        content_type:
          type: string
        size_bytes:
          type: integer
          format: int64
          description: Declared at creation; re-verified against the actual upload at complete.
        sha256:
          type: string
          description: Lowercase hex sha256, declared at creation and re-verified against the actual upload at complete.
        parse_status:
          type: string
          enum:
            - pending
            - uploaded
            - parsing
            - parsed
            - degraded_parse
            - parse_failed
            - unsupported_pending
          description: "`unsupported_pending`: the upload was accepted and verified, but its matched adapter had not landed yet at complete time — stored, never rejected, and backfilled once the adapter ships."
        parse_started_at:
          type: string
          format: date-time
          description: |-
            Until it existed, NOTHING on this resource changed between `parsing` and `parsed`: page_count and parser_version are both written by the terminal transaction, so a 24-page table-dense guideline parsed for 4m48s with no field a poll could render, and the same file took 15s on production — a client cannot tell slow from wedged without a clock.
            It is a clock, not a bar. Compute pace/elapsed from this, as `DirectiveExtractionProgress.started_at` is already used.
            ABSENT is a normal shape (still `pending`/`uploaded`, or a parse that ran before this field existed): render the plain status. Never reset — a reparse is a new guideline version.
        page_count:
          type: integer
          description: "Present once parsed. Unlike a scenario document's `page_count`, this arrives ONLY with the terminal transition: a guideline's pages and its parse_status are written in one transaction (MaterializeParse), so there is no window in which it reports partial progress. Use `parse_started_at` for liveness."
        parser_version:
          type: string
          description: Present once parsed.
        supersedes_guideline_id:
          type: string
          format: uuid
          description: Set when this upload is a revised edition of an existing guideline in this group.
        uploaded_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time
        erased_at:
          type: string
          format: date-time
          description: |-
            When this guideline's content was erased (per-record deletion). Present only on an erased guideline: the uploaded filename, the parsed layout text and the extracted criteria's normative text have all been overwritten with an explicit tombstone marker and the underlying blob bytes destroyed. Ids, block geometry, canonical keys and every `source_block_ids` citation still resolve — to an explicit "content erased" state, never a dangle. An erased guideline is refused with 409 `guideline_erased` by its download, its re-parse retry (`.../complete`) and question generation — the three paths that would need the destroyed bytes or text back — and the questions it produced are FLAGGED for review rather than deleted (`Question.source_content_erased`).
            `display_name` SURVIVES and `filename` does not. The display name is the workspace's own words for this rulebook — its authoring config, not the data subject's file — so it is classified as structure and is never tombstoned. `filename` is the uploaded file's own name and IS tombstoned to the marker `[erased]`. Title a surface with `display_name`; never render `filename` once this field is set.
    GuidelineErasureRequest:
      type: object
      required:
        - reason
      properties:
        reason:
          type: string
          minLength: 1
          description: Mandatory recorded justification (e.g. the DSR/contract request reference). Carried on the audit trail; an irreversible act needs a recorded why.
    GuidelineErasure:
      type: object
      description: Acknowledgement that a guideline-content erasure was accepted and its workflow started. Ids and flags only — the erasure's full per-table accounting lands on the `guideline.erased` audit event, and `Guideline.erased_at` is the queryable completion anchor.
      required:
        - guideline_id
        - question_group_id
        - already_erased
        - requested_at
      properties:
        guideline_id:
          type: string
          format: uuid
        question_group_id:
          type: string
          format: uuid
          description: "The group this guideline belongs to — the same reason the preview carries it: this route is addressed by the guideline id alone, and every other guideline read is nested under the group, so without this a caller cannot poll `Guideline.erased_at` (the completion anchor this 202 points at)."
        already_erased:
          type: boolean
          description: True when the guideline already carried an erasure anchor at request time; the run then re-verifies/completes the earlier erasure (idempotent by design) rather than starting a new one.
        requested_at:
          type: string
          format: date-time
    GuidelineErasurePreview:
      type: object
      description: |-
        The dry-run blast radius of a guideline-content erasure — what erasing THIS guideline would tombstone, flag, delete and retain, so a person consents to consequences rather than to an id. Counts and the guideline's own handle only; computed read-only, nothing written.

        No `related_copies` here: guidelines are never copied — only a scenario's documents cross a derive — so a guideline erasure is never split across siblings the way a document erasure can be.
      required:
        - guideline_id
        - question_group_id
        - filename
        - parse_status
        - already_erased
        - erasable
        - criteria_to_erase
        - questions_to_flag
        - cache_entries_to_delete
        - cache_entries_retained_shared
        - blob_objects
      example:
        guideline_id: 0193f2c4-1e00-7000-8000-0000000000bb
        question_group_id: 0193f2c4-1e00-7000-8000-0000000000cc
        filename: underwriting-manual-2026.pdf
        parse_status: parsed
        already_erased: false
        erasable: true
        criteria_to_erase: 42
        questions_to_flag: 7
        cache_entries_to_delete: 2
        cache_entries_retained_shared: 1
        blob_objects: 26
      properties:
        guideline_id:
          type: string
          format: uuid
        question_group_id:
          type: string
          format: uuid
          description: The group this guideline belongs to. Carried because every other guideline read is NESTED under the group (`/question-groups/{questionGroupId}/guidelines/{guidelineId}`) while the erasure pair is addressed by the guideline's globally unique id alone — so a caller holding only that id would have no way back to the resource it just previewed.
        filename:
          type: string
          description: |-
            The uploaded file's name, shown to the human deciding so they can
            see they are about to erase the guideline they meant. It is about
            to be tombstoned, and this read-only call persists it nowhere.

            **When `already_erased` is true this is already the marker
            `[erased]`, not a name.** Never present it to a person as one, and
            never use it as a type-to-confirm target: every erased record
            carries the identical string, so a confirmation gate keyed on it
            accepts the same input for all of them and checks nothing.
        parse_status:
          type: string
          enum:
            - pending
            - uploaded
            - parsing
            - parsed
            - degraded_parse
            - parse_failed
            - unsupported_pending
          description: The guideline's current parse state — the reason an erasure can be refused. Same closed set as `Guideline.parse_status`, whose members differ from a scenario document's (`parse_failed` here, not `failed`).
        already_erased:
          type: boolean
          description: True when this guideline's content is already destroyed. A repeat erasure is legal and idempotent (it finishes or re-verifies the earlier pass) — it is not a second destruction.
        erased_at:
          type: string
          format: date-time
          description: Present only when `already_erased` — when the content was FIRST destroyed. It never moves.
        erasable:
          type: boolean
          description: "Whether an erasure would proceed right now. False only while the guideline's own parse is mid-pipeline: the parse is concurrently writing the very layout rows the erasure would tombstone, so the erasure waits for a terminal parse state (409 `parse_in_flight`)."
        criteria_to_erase:
          type: integer
          format: int64
          description: How many extracted criteria would have their normative text, applicability and thresholds tombstoned. The criteria ROWS survive as anchors — their ids, their `source_block_ids` citations and any chain they belong to are untouched — so everything pointing at them keeps resolving.
        questions_to_flag:
          type: integer
          format: int64
          description: "How many generated questions cite one of those criteria and would be FLAGGED (`Question.source_content_erased`). They are not deleted and not edited: a published question version is immutable and may have been human-edited or drawn from several guidelines, so the erasure records that the source content is gone and leaves the re-authoring decision to a person."
        blob_objects:
          type: integer
          format: int64
          description: Stored objects to be deleted — the original uploaded bytes, the parsed JSON, the page renders, and any exclusive cache artifacts.
        cache_entries_to_delete:
          type: integer
          format: int64
          description: "Cached parse/generation entries whose every recorded lookup was this guideline's: index row AND content-addressed artifact both go. Those artifacts hold the parsed guideline text and the generated criteria verbatim, which is why they are part of the erasure rather than a leftover."
        cache_entries_retained_shared:
          type: integer
          format: int64
          description: Cached entries this guideline shares with another recorded lookup, which therefore STAY. A retained entry means another workspace uploaded a byte-identical file and still holds that content in its own rows and objects, so deleting the shared entry would degrade a live consumer without shrinking the residue. Disclosed as a count so the residue is sized before the erasure, not discovered after it.
    CreateGuidelineRequest:
      type: object
      required:
        - filename
        - content_type
        - size_bytes
        - sha256
      properties:
        filename:
          type: string
        content_type:
          type: string
        size_bytes:
          type: integer
          format: int64
          description: Declared size in bytes; re-verified against the actual upload at complete.
        sha256:
          type: string
          description: Declared lowercase hex sha256; re-verified against the actual upload at complete.
        supersedes_guideline_id:
          type: string
          format: uuid
          description: Set when this upload is a revised edition of an existing guideline in this group.
    UpdateGuidelineRequest:
      type: object
      description: "PATCH semantics — an omitted field is unchanged; at least one field is required. Only `display_name` is mutable: everything else on a guideline is upload provenance, fixed at creation."
      minProperties: 1
      properties:
        display_name:
          type: string
          minLength: 1
          maxLength: 200
          description: The guideline's human name; must contain a non-whitespace character.
    GuidelineUpload:
      type: object
      required:
        - method
        - url
        - headers
        - expires_at
      properties:
        method:
          type: string
          enum:
            - PUT
        url:
          type: string
          description: Presigned PUT URL.
        headers:
          type: object
          description: Headers the client must send with the PUT (Content-Type is SigV4-signed into the URL and must match exactly).
          additionalProperties:
            type: string
        expires_at:
          type: string
          format: date-time
    CreateGuidelineResponse:
      type: object
      required:
        - guideline
        - upload
      properties:
        guideline:
          $ref: "#/components/schemas/Guideline"
        upload:
          $ref: "#/components/schemas/GuidelineUpload"
    GuidelineList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Guideline"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    GetGuidelineDownloadResponse:
      type: object
      required:
        - guideline
        - download_url
        - download_expires_at
      properties:
        guideline:
          $ref: "#/components/schemas/Guideline"
        download_url:
          type: string
          description: Presigned GET URL.
        download_expires_at:
          type: string
          format: date-time
    QuestionSet:
      type: object
      description: A named question-set container.
      required:
        - id
        - question_group_id
        - name
        - created_at
      properties:
        id:
          type: string
          format: uuid
        question_group_id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
        vertical:
          type: string
          description: The kind of underwriting this rulebook is written for. A case may only be underwritten against a question set whose vertical matches its own — binding a case to a mismatched set is a 422 at `POST /v1/scenarios`, `PATCH /v1/scenarios/{id}` and `POST /v1/scenarios/{id}/evaluate`. Absent for the shipped default vertical. Set at creation (explicitly, or inherited from the tenant's own binding), never changed afterwards.
        program:
          type: string
          description: 'Which rulebook FAMILY within that vertical this set is written for. A case may only be underwritten against a question set whose program matches its own — the same 422 at the same three doors as the vertical rule. Absent means "no program distinction", which pairs only with cases that also carry none. Set at creation and never changed afterwards: a rulebook written for a different program is a different set.'
        current_version_id:
          type: string
          format: uuid
          description: UI convenience only — set at first publish and thereafter to the latest published version. Never used to pin a scenario's evaluation.
        active_version_summary:
          $ref: "#/components/schemas/ActiveVersionSummary"
        created_at:
          type: string
          format: date-time
    ActiveVersionSummary:
      type: object
      description: The set's ACTIVE version (the live pre-publication version if one exists, else the current published version, else the highest version present — the same selection use-question-set-versions.ts's selectActiveVersion makes client-side) reduced to what dashboard-style summaries need. Computed server-side in one batched query and inlined onto every QuestionSet a list call returns, so a dashboard never has to fan `GET.../versions` out per set to answer "how many sets are pending review / carrying flagged questions" (that fan-out was profiled at 183 requests for one dashboard load).
      required:
        - id
        - version
        - status
        - flagged_count
      properties:
        id:
          type: string
          format: uuid
        version:
          type: integer
        status:
          $ref: "#/components/schemas/QuestionSetVersionStatus"
        flagged_count:
          type: integer
    QuestionSetList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/QuestionSet"
        next_cursor:
          type: string
    CreateQuestionSetRequest:
      type: object
      required:
        - question_group_id
        - name
      properties:
        question_group_id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
        vertical:
          type: string
          description: Optional vertical this rulebook is written for (snake_case); must name a vertical present in the tenant's document-type taxonomy (422 otherwise). Omitted means the tenant's own binding, which is the shipped default vertical for an unbound tenant.
          pattern: ^[a-z][a-z0-9_]*$
        program:
          type: string
          description: "Optional program this rulebook is written for (snake_case) — which rulebook family inside the vertical. Values are your own and there is no catalog to register one in; what is enforced is the pairing (a case may only be underwritten against a set carrying the same program) and immutability. Omitted means \"no program distinction\", which pairs with every case that carries none — including every case that predates programs entirely. There is no tenant-level default program. Because there is no catalog, THIS is where a tenant's programs come from: the set of values its question sets carry is the set of programs it runs."
          pattern: ^[a-z][a-z0-9_]*$
    CreateQuestionSetResponse:
      type: object
      required:
        - set
        - draft
      properties:
        set:
          $ref: "#/components/schemas/QuestionSet"
        draft:
          $ref: "#/components/schemas/QuestionSetVersion"
    QuestionSetVersionStatus:
      type: string
      description: "Forward-only state machine. `publishing` and `publish_failed` are the async-publish sub-machine: both are PRE-publication (questions frozen against editing, no second draft possible), and a `publish_failed` version is either retried or reopened to `draft`."
      enum:
        - draft
        - in_review
        - publishing
        - published
        - publish_failed
        - superseded
        - archived
    ChangeSummary:
      type: object
      description: Lineage-id-only summary filled in at publish.
      properties:
        added:
          type: array
          items:
            type: string
            format: uuid
        modified:
          type: array
          items:
            type: string
            format: uuid
        removed:
          type: array
          items:
            type: string
            format: uuid
    QuestionSetVersion:
      type: object
      required:
        - id
        - question_set_id
        - version
        - status
        - source_guideline_ids
        - question_count
        - flagged_count
        - created_at
      properties:
        id:
          type: string
          format: uuid
        question_set_id:
          type: string
          format: uuid
        version:
          type: integer
          description: Per-set monotonically increasing integer, assigned at draft creation.
        status:
          $ref: "#/components/schemas/QuestionSetVersionStatus"
        source_guideline_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Guideline sources that fed this version; empty for pure manual authoring.
        parent_version_id:
          type: string
          format: uuid
        change_summary:
          $ref: "#/components/schemas/ChangeSummary"
        question_count:
          type: integer
        flagged_count:
          type: integer
          description: How many questions in this version currently carry a consistency-mismatch review flag. Read from the stored per-question verdict; it is not re-checked on read.
        created_by:
          type: string
          format: uuid
        published_by:
          type: string
          format: uuid
        published_at:
          type: string
          format: date-time
        publish_started_at:
          type: string
          format: date-time
          description: When the current (or last) async publish attempt began.
        publish_error:
          $ref: "#/components/schemas/PublishError"
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscription"
          description: The task-notification subscriptions registered for THIS version's publish task (task_kind `question_set_publish`). Populated ONLY on the publish POST's `202` response (rider), so a client that published with `notify_on_completion`/`notifications[]` sees exactly what was enqueued without a second fetch. Absent on every other endpoint that returns a version (list/get/reopen) — a version resource does not otherwise carry its publish subscriptions.
        created_at:
          type: string
          format: date-time
      description: A unit of publication of a question set.
    PublishError:
      type: object
      description: The typed failure a version in `publish_failed` carries. `code` is the same problem code the request would have returned had the publish been synchronous — `unavailable` when the consistency gate could not run and failed closed, `validation` when the question logic did not pass, and so on.
      required:
        - code
        - message
        - stage
        - failed_at
      properties:
        code:
          type: string
        message:
          type: string
        stage:
          type: string
          description: Which publish stage failed (`validating`, `consistency`, `committing`).
        failed_at:
          type: string
          format: date-time
    PublishQuestionSetVersionRequest:
      type: object
      description: Optional body on the publish POST — completion-notification opt-ins only (task_kind `question_set_publish`). Publishing takes no other parameters; an absent body is equivalent to `{}`.
      properties:
        notify_on_completion:
          type: boolean
          description: Sugar for one email task_notification_subscriptions row to the requesting user, sent once the publish reaches a terminal state. Ignored for API-key callers — there is no user mailbox to notify.
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscriptionInline"
          description: Inline task-notification subscriptions.
    QuestionSetPublishStatus:
      type: object
      description: The async publish's poll read model.
      required:
        - question_set_version_id
        - question_set_id
        - version
        - status
        - question_count
        - checked_count
        - flagged_count
      properties:
        question_set_version_id:
          type: string
          format: uuid
        question_set_id:
          type: string
          format: uuid
        version:
          type: integer
        status:
          $ref: "#/components/schemas/QuestionSetVersionStatus"
        stage:
          type: string
          description: Derived in-flight stage — `consistency` while the review runs, `committing` once its verdicts are checkpointed. Absent once the publish is terminal.
          enum:
            - consistency
            - committing
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          description: The version's published_at, once published.
        workflow_id:
          type: string
          description: The identifier of the background job driving this publish. For support traceability.
        question_count:
          type: integer
        checked_count:
          type: integer
          description: How many of this version's questions already carry a checkpointed consistency verdict for this publish attempt. 0 while the review is still running; a resumed publish reloads these and makes no further LLM call.
        flagged_count:
          type: integer
          description: How many questions currently carry a consistency-mismatch review flag.
        error:
          $ref: "#/components/schemas/PublishError"
    QuestionSetVersionList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/QuestionSetVersion"
        next_cursor:
          type: string
    CreateQuestionSetVersionRequest:
      type: object
      properties:
        from_version:
          type: string
          description: Base version to copy from (version number or version id). Defaults to the set's current published version; required if the set has never been published.
    Criticality:
      type: string
      enum:
        - eligibility
        - documentation
        - data_integrity
      description: How much a question's answer matters. A closed set.
    QuestionProvenance:
      type: string
      enum:
        - generated
        - human_edited
        - human_authored
      description: Where a question came from, and whether a person owns it.
    EvidenceRequirements:
      type: object
      required:
        - doc_types
        - fact_paths
      properties:
        doc_types:
          type: array
          items:
            type: string
          description: 'Tenant/vertical taxonomy values — shape only, not vocabulary, is validated here. Routing is a strict key-join over this list, so an EMPTY list routes the question to no document at all: it is UNROUTED, never "every document" (see `any_document`). An unrouted question with no `fact_paths` either is reported by the publish-time authoring lint (`authoring_findings`).'
        fact_paths:
          type: array
          items:
            type: string
          description: Paths into the structured facts a question needs. Read `GET /v1/canonical-keys` for the shared vocabulary they draw on.
        any_document:
          type: boolean
          description: 'Directive documents are still excluded: they are a rule channel, never evidence. Absent/false = the ordinary `doc_types` key-join. This is deliberately a separate flag rather than a re-reading of an empty `doc_types`: every question authored before this field existed has an empty list, and empty must keep meaning "unrouted" so published question sets do not retroactively change what they evaluate.'
        note:
          type: string
      description: What a question requires before it can be answered.
    AuthoringFinding:
      type: object
      description: "One advisory hit from the lint that runs when a question set is published: logic that could only ground an answer in the ABSENCE of evidence, a definitive verdict with nothing citable behind it, or an evidence requirement naming a document type that carries rules rather than evidence — a requirement no upload can ever satisfy. Advisory by design: a finding never blocked the publish and never changes the consistency verdict. It is a reviewer's warning that the question will abstain, or evaluate nothing, when it runs."
      required:
        - node_id
        - kind
        - detail
      properties:
        node_id:
          type: string
          description: The node of the question's logic that the finding is about.
        kind:
          type: string
          description: "Stable finding identifier — `absence_prompt_gate`, `ungrounded_na_gate`, `ungrounded_verdict_node` or `directive_evidence_doc_type` today. An open string: new rules are added without a breaking change, so clients must render an unrecognized kind from `detail` rather than drop it. At most one finding exists per (`node_id`, `kind`) pair, so that pair is a safe de-duplication key."
        detail:
          type: string
          description: A reviewer-facing sentence naming the defect and the fix.
    Question:
      type: object
      description: "A per-version question snapshot. `logic` and `answer_schema` carry the full JSON documents (conditional AST / typed answer schema); this API accepts them as opaque JSON and enforces their shape server-side (422 with JSON-Pointer errors under `/logic`, `/answer_schema` on violation) rather than re-encoding the recursive AST as an OpenAPI schema (notes the same bounded-depth workaround for the same reason: providers cannot guarantee recursive $ref enforcement)."
      required:
        - id
        - lineage_id
        - ordinal
        - text_md
        - logic
        - answer_schema
        - evidence_requirements
        - criticality
        - provenance
        - created_at
      properties:
        id:
          type: string
          format: uuid
        lineage_id:
          type: string
          format: uuid
          description: Stable identity across versions — diffs, answers, and overrides join on this.
        ordinal:
          type: integer
        text_md:
          type: string
        logic:
          type: object
          additionalProperties: true
          description: Conditional logic AST (check/gate/group nodes).
        answer_schema:
          type: object
          additionalProperties: true
          description: "Root-node answer-schema convenience copy — server-derived from `logic`'s root node, never client-writable: `{}` for `group` roots, which carry no root-level answer schema. There is deliberately no `readOnly: true` marker here — QuestionCreate/QuestionPatch are already distinct request-only schemas that never declare this property at all (rather than declaring-and-ignoring it), which is the stronger \"never client-writable\" guarantee; adding readOnly to this standalone response schema would only flip the generated Go/TS field to optional with no enforcement benefit."
        evidence_requirements:
          $ref: "#/components/schemas/EvidenceRequirements"
        criticality:
          $ref: "#/components/schemas/Criticality"
        category:
          type: string
        source_criteria:
          type: array
          items:
            type: string
            format: uuid
          description: Empty for human_authored questions.
        provenance:
          $ref: "#/components/schemas/QuestionProvenance"
        generation_meta:
          type: object
          additionalProperties: true
          description: "{run_id, model, prompt_versions, rubric_score} — present only for provenance=generated. The `model` key is PLATFORM-ONLY and is removed for every non-platform principal; run id, prompt versions and rubric score are unaffected."
        consistency_review:
          type: object
          additionalProperties: true
          nullable: true
          description: "Text_md/logic consistency verdict — {consistent, explanation, model, prompt_version, checked_at}. The `model` key is PLATFORM-ONLY and is removed for every non-platform principal; the verdict and explanation are unaffected. Refreshed at publish whenever text_md or logic changed since the last check (content-hash keyed); null until the question has been through its first publish gate. A mismatch (`consistent: false`) is a review flag for a human to confirm — it never blocked this question's publish. The authoring lint's findings are carried in the same stored verdict but are lifted OUT of this blob onto the typed `authoring_findings` property below, so they have exactly one home on the wire and are never subject to identity redaction."
        authoring_findings:
          type: array
          items:
            $ref: "#/components/schemas/AuthoringFinding"
          description: "Advisory authoring-lint findings for this question, refreshed on every publish (surfaced to authors). Empty for a question that has never been through a publish gate — the lint runs at publish, so a brand-new draft has no findings yet. Never redacted: a finding names a node and a defect, never a model."
        source_content_erased:
          type: boolean
          description: |-
            True when at least one of this question's `source_criteria` was extracted from a guideline whose content has since been ERASED. The question itself is untouched — a published version is immutable, and it may have been human-edited or drawn from several guidelines — so the erasure flags it for review rather than deleting or rewriting it: the text stands, but the source it was derived from can no longer be read back, so a person owes it a re-reading against whatever replaces that guideline.
            ABSENT MEANS EXACTLY ONE THING: this question has no erased source. It is absent rather than `false` so an ordinary set of several hundred intact questions does not ship several hundred pointers to nothing, and it is always absent for `human_authored` questions, which have no source criteria at all. The read that resolves it FAILS THE REQUEST if it cannot answer (DX review): a review prompt that goes quietly missing on a question whose source evidence was destroyed is worse than a visible outage, so `absent` never means "we could not check".
            Stamped on every question read — the paginated list, the unpaginated `questions:all`, the single-question GET, and the response to a PATCH.
        created_at:
          type: string
          format: date-time
    QuestionList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Question"
        next_cursor:
          type: string
    AllQuestionsResponse:
      type: object
      description: GET.../questions:all — every question of the version; there is no next page, ever.
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Question"
          description: In display order (ordinal, id) — see ListAllQuestionsByVersion.
    QuestionCreate:
      type: object
      description: "provenance is always forced to human_authored server-side. `answer_schema` is deliberately absent: it is a server-derived convenience column, computed from `logic`'s root node and never accepted from a client (any `answer_schema` sent here is ignored)."
      required:
        - text_md
        - logic
        - evidence_requirements
        - criticality
      properties:
        text_md:
          type: string
        logic:
          type: object
          additionalProperties: true
        evidence_requirements:
          $ref: "#/components/schemas/EvidenceRequirements"
        criticality:
          $ref: "#/components/schemas/Criticality"
        category:
          type: string
        ordinal:
          type: integer
          description: Defaults to appending at the end of the version's question list.
    QuestionPatch:
      type: object
      description: 'All fields optional; only supplied fields are changed ("Edit"). `answer_schema` is deliberately absent (see QuestionCreate): patching `logic` re-derives it server-side.'
      properties:
        text_md:
          type: string
        logic:
          type: object
          additionalProperties: true
        evidence_requirements:
          $ref: "#/components/schemas/EvidenceRequirements"
        criticality:
          $ref: "#/components/schemas/Criticality"
        category:
          type: string
        ordinal:
          type: integer
    DiffEntryChange:
      type: string
      enum:
        - added
        - removed
        - modified
    DiffEntry:
      type: object
      required:
        - lineage_id
        - change
      properties:
        lineage_id:
          type: string
          format: uuid
        change:
          $ref: "#/components/schemas/DiffEntryChange"
        before:
          $ref: "#/components/schemas/Question"
        after:
          $ref: "#/components/schemas/Question"
        changed_fields:
          type: array
          items:
            type: string
    QuestionSetDiff:
      type: object
      required:
        - from_version
        - to_version
        - entries
      properties:
        from_version:
          type: string
          format: uuid
        to_version:
          type: string
          format: uuid
        entries:
          type: array
          items:
            $ref: "#/components/schemas/DiffEntry"
    StructureQuestionRequest:
      type: object
      required:
        - text
      properties:
        text:
          type: string
          description: The author's plain-text requirement.
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscriptionInline"
          description: Inline task-notification subscriptions.
    StructureTaskStatus:
      type: string
      description: question_structure_tasks state machine.
      enum:
        - queued
        - completed
        - failed
    StructureTask:
      type: object
      description: One authoring-structuring assist run — the queued-task replacement for the old synchronous questions:structure call. Once completed, `proposal`/`model`/`prompt_version`/`ambiguities`/`notes` carry exactly what StructureQuestionResponse used to return synchronously; the human's confirm/edit review step is unchanged.
      required:
        - id
        - question_set_id
        - question_set_version_id
        - status
        - ambiguities
        - created_at
      properties:
        id:
          type: string
          format: uuid
        question_set_id:
          type: string
          format: uuid
        question_set_version_id:
          type: string
          format: uuid
        status:
          $ref: "#/components/schemas/StructureTaskStatus"
        proposal:
          $ref: "#/components/schemas/QuestionCreate"
        model:
          type: string
          description: PLATFORM-ONLY (internal operators). The model that produced the proposal. Absent until completed, and ALWAYS absent for a non-platform principal (keep model choice a config detail; customers never see AI mechanism). `prompt_version` below is NOT gated — it names our own template, not a vendor.
        prompt_version:
          type: string
          description: The authoring_structuring template version used (absent until completed).
        ambiguities:
          type: array
          items:
            type: string
          description: Every point the author should confirm (unclear text, or an interpretation the model chose).
        notes:
          type: string
          description: Anything else the author should know.
        error_detail:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    StructureTaskList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/StructureTask"
        next_cursor:
          type: string
    GenerationRunStatus:
      type: string
      description: "generation_runs state machine. `canceled` is a THIRD terminal status alongside completed/failed: someone stopped the run via POST.../generation-runs/{id}/cancel. A canceled run keeps whatever it had already spent (`stats.cost_usd`) and keeps its completed checkpoints — a cancel is a stop, not a rollback — but it does not advance, and the set is free for a fresh run."
      enum:
        - queued
        - extracting
        - reconciling
        - drafting
        - reviewing
        - completed
        - failed
        - canceled
    GenerationRunStats:
      type: object
      description: Progress/outcome numbers for the run.
      properties:
        blocks_total:
          type: integer
        blocks_dispositioned:
          type: integer
        criteria_count:
          type: integer
        questions_drafted:
          type: integer
        non_normative_count:
          type: integer
        unaccounted_count:
          type: integer
        cost_usd:
          type: number
          format: double
          description: |-
            PLATFORM-ROLE-ONLY — absent for every other principal, and `progress.spend_withheld` on the single-run GET says out loud when that is why it is absent.
            On the single-run GET it is LIVE — recomputed from the ledger on every poll, so it equals `progress.spend_so_far` exactly. Everywhere else (list rows, the start 202) it is the last value persisted at a STAGE BOUNDARY, which mid-run lags the ledger by up to a whole stage: extraction and drafting each bill many calls between boundaries, so a list row can read $0 on a run that has already spent double digits. Poll the run for a spend figure; read a list row's for bookkeeping only. Both agree once the run is terminal.
        windows:
          type: integer
        duplicates_merged:
          type: integer
        unrepresented_count:
          type: integer
        needs_review_count:
          type: integer
        criteria_dropped:
          type: integer
          description: "Scoped to that check only: it is not a count of every criterion the run discarded, and in particular excludes the candidate filter's later drops of unstatable text (headings, placeholders, fragments), which stay audit-only. Each one used to fail the whole run; degrading the item instead is what lets a large table-dense guideline finish, so the count has to be visible or the degradation is silent. A run with a large share of its criteria dropped produced a thin question set for a machine reason, not a content one — treat it as a review signal, not a progress number. Absent (0) on a clean run."
        citations_dropped:
          type: integer
          description: A criterion that lost SOME of its citations is kept (the survivors still ground it); one that lost all of them is counted in criteria_dropped instead. Absent (0) on a clean run.
        batches_total:
          type: integer
          description: Total drafting batches this run planned — known before batch 1 drafts (the batch plan is deterministic over the run's already- extracted criteria).
        batches_completed:
          type: integer
          description: Drafting batches checkpointed so far (durable — survives a worker restart). Ticks up live while status is "drafting"/"reviewing"; equals batches_total once the run completes.
        questions_drafted_so_far:
          type: integer
          description: Questions drafted and reviewed across checkpointed batches so far. Reflects genuinely completed pipeline work — not yet- committed question rows, which land in one bulk insert once the whole batch loop finishes; equals questions_drafted once the run completes.
        budget_paused:
          type: boolean
          description: The run HALTED because its ledger spend met budget_limit_usd. `status` deliberately keeps its non-terminal stage value — a budget stop is not terminal, so the run's stage checkpoints survive and a resume after the ceiling is raised re-spends nothing. Absent on every run of a deployment with no configured ceiling (the default).
        budget_paused_stage:
          type: string
          description: Which spending stage refused to spend further when the run paused ("extraction" or "drafting"). Present only alongside budget_paused.
        budget_limit_usd:
          type: number
          format: double
          description: The per-run spend ceiling in force for this run. Echoed whenever a ceiling is configured, so a poll can show "spent of limit" without reading the worker's configuration; absent when generation runs are unlimited.
        vendor_paused:
          type: boolean
          description: "The run HALTED because an AI-service outage outlasted the stage's retries, and it is being retried AUTOMATICALLY on a widening schedule. Like budget_paused this is not terminal — `status` keeps its non-terminal stage value, the run's stage checkpoints and already-drafted questions survive, and the automatic resume picks up exactly where it stopped at no extra cost. A client MUST NOT render this as ordinary in-progress work: the run is stopped, and the honest message is that it is retrying by itself. Absent on every run that never paused this way."
        vendor_paused_stage:
          type: string
          description: Which stage was running when the run paused ("extraction" or "drafting"). Present only alongside vendor_paused.
        vendor_paused_detail:
          type: string
          description: A plain-language sentence describing the hold and what it means for the customer's work. Drawn from a fixed set of our own sentences — never provider text — so it names no AI vendor, model or family, and never advises starting the work over (which the one-active-run rule would refuse while the run is paused). Present only alongside vendor_paused.
        vendor_next_probe_at:
          type: string
          format: date-time
          description: When the next automatic availability check is due. Widens as checks keep failing (about a minute, then five, then fifteen, then hourly), which is why it rides as its own field rather than being baked into vendor_paused_detail — a sentence with an instant in it would start lying on the first widening. Absent once vendor_escalated is true (nothing further is scheduled).
        vendor_probe_attempts:
          type: integer
          description: How many automatic availability checks have failed so far. Present only alongside vendor_paused; 0/absent until the first check runs.
        vendor_escalated:
          type: boolean
          description: The automatic retries were exhausted and have stopped; the run stays paused with everything intact and now needs someone to resume it (POST.../generation-runs/{generationRunId}/resume). A client should switch its message from "retrying automatically" to "needs you" here — this is the one vendor-pause state that is waiting on a human.
    GenerationRun:
      type: object
      description: One execution of the generation pipeline.
      required:
        - id
        - question_set_id
        - draft_version_id
        - source_guideline_ids
        - status
        - stats
        - created_at
      properties:
        id:
          type: string
          format: uuid
        question_set_id:
          type: string
          format: uuid
        draft_version_id:
          type: string
          format: uuid
        source_guideline_ids:
          type: array
          items:
            type: string
            format: uuid
        status:
          $ref: "#/components/schemas/GenerationRunStatus"
        stats:
          $ref: "#/components/schemas/GenerationRunStats"
        error_detail:
          type: string
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
        notify_on_completion:
          type: boolean
          default: false
          description: Whether the person who started this run asked, at the time, to be emailed once it finishes.
        notified_at:
          type: string
          format: date-time
          description: When the opt-in completion email was actually sent (absent if never sent).
        progress:
          $ref: "#/components/schemas/RunProgress"
          description: Stage-sequenced progress (extract → draft → review → publish), derived from the run's stage checkpoints + stats — batches count only exists after extraction (units_total absent until then, never faked). Present on the single-run GET (the progress-polling resource); absent on list rows and the start 202 (the first poll fills it in). spend_so_far and stats.cost_usd are the SAME live ledger total on this payload — equal on every poll, by construction (before that, stats.cost_usd was a stage-boundary snapshot and the two disagreed for most of a run's life). spend_ceiling mirrors stats.budget_limit_usd. When the principal may not see figures, all four are absent together and spend_withheld is true.
    GenerationRunList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/GenerationRun"
        next_cursor:
          type: string
    GenerateRequest:
      type: object
      required:
        - guideline_ids
      properties:
        guideline_ids:
          type: array
          minItems: 1
          items:
            type: string
            format: uuid
          description: Parsed guideline sources (same question group as the set) to generate from.
        notify_on_completion:
          type: boolean
          description: Omitted or false means no notification (the server default). Ignored (never sent) for an API-key-triggered run — there is no user mailbox to notify. sugar for one email task_notification_ subscriptions row — additive/alongside the notifications array below, not a replacement for it.
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscriptionInline"
          description: Inline task-notification subscriptions.
    CriterionThreshold:
      type: object
      required:
        - name
        - value
        - unit
      properties:
        name:
          type: string
        value:
          type: number
          format: double
          description: Numeric value of the threshold. 0 when the source states the threshold non-numerically (a date, a domain notation) — in that case read `text`/`kind`, not `value`.
        text:
          type: string
          description: Verbatim textual form of a non-numeric threshold (e.g. a mandatory-arbitration cutoff date "2004-10-31" or a credit-history notation "1x30x12"). Absent for ordinary numeric thresholds.
        kind:
          type: string
          description: Classification of a non-numeric threshold — `date` (safely coerced to a calendar date) or `text` (stored verbatim, machine comparison unavailable). Absent for numeric thresholds. A plain string rather than an enum so the criterion vocabulary can grow additively without a breaking regenerated-client change.
        unit:
          type: string
    Criterion:
      type: object
      description: One extracted normative rule.
      required:
        - id
        - normative_text
        - kind
        - topic
        - thresholds
        - referenced_sections
        - source_block_ids
        - created_at
      properties:
        id:
          type: string
          format: uuid
        normative_text:
          type: string
        kind:
          type: string
          enum:
            - requirement
            - conditional_requirement
            - prohibition
            - permission
        topic:
          type: string
        applicability:
          type: string
          description: Free-form condition text; absent when unconditional.
        thresholds:
          type: array
          items:
            $ref: "#/components/schemas/CriterionThreshold"
        referenced_sections:
          type: array
          items:
            type: string
        source_block_ids:
          type: array
          items:
            type: string
            format: uuid
        extraction_model:
          type: string
          description: PLATFORM-ONLY (internal operators). The model key that extracted this criterion. ABSENT for every non-platform principal; optional (not required) so it can be OMITTED rather than blanked to "".
        created_at:
          type: string
          format: date-time
    CriterionList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Criterion"
        next_cursor:
          type: string
    CoverageReport:
      type: object
      description: The partition proof over guideline_coverage_ledger.
      required:
        - generation_run_id
        - blocks_total
        - criterion_source
        - non_normative
        - non_normative_reasons
        - unaccounted
        - unaccounted_block_ids
      properties:
        generation_run_id:
          type: string
          format: uuid
        blocks_total:
          type: integer
        criterion_source:
          type: integer
        non_normative:
          type: integer
        non_normative_reasons:
          type: object
          additionalProperties:
            type: integer
        unaccounted:
          type: integer
          description: Must be 0 for a completed run.
        unaccounted_block_ids:
          type: array
          items:
            type: string
            format: uuid
    StartDiffRunRequest:
      type: object
      required:
        - guideline_source_id
      properties:
        guideline_source_id:
          type: string
          format: uuid
          description: The re-uploaded guideline version to diff against the set's currently published version.
        notify_on_completion:
          type: boolean
          description: Omitted or false means no notification (the server default). Ignored (never sent) for an API-key-triggered run — there is no user mailbox to notify. sugar for one email task_notification_subscriptions row — additive/alongside notifications below.
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscriptionInline"
          description: Inline task-notification subscriptions.
    DiffRunStatus:
      type: string
      description: question_set_diff_runs state machine.
      enum:
        - computing
        - ready_for_review
        - applied
        - discarded
        - failed
    DiffRunStats:
      type: object
      properties:
        unchanged_count:
          type: integer
        add:
          type: integer
        update:
          type: integer
        delete:
          type: integer
        flagged:
          type: integer
        llm_pairs:
          type: integer
        cost_usd:
          type: number
          format: double
          description: PLATFORM-ROLE-ONLY — absent for every other principal, exactly as `GenerationRunStats.cost_usd` is.
      description: Progress and outcome numbers for the run.
    DiffRun:
      type: object
      description: One execution of the re-upload anchor-match diff pipeline.
      required:
        - id
        - question_set_id
        - from_version_id
        - guideline_source_id
        - generation_run_id
        - status
        - stats
        - created_at
      properties:
        id:
          type: string
          format: uuid
        question_set_id:
          type: string
          format: uuid
        from_version_id:
          type: string
          format: uuid
          description: The previously published version being diffed against.
        to_draft_version_id:
          type: string
          format: uuid
          description: Set once the run is applied; absent before then.
        guideline_source_id:
          type: string
          format: uuid
        generation_run_id:
          type: string
          format: uuid
          description: The underlying G1-G6 run that produced the new criteria set.
        status:
          $ref: "#/components/schemas/DiffRunStatus"
        stats:
          $ref: "#/components/schemas/DiffRunStats"
        error_detail:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        notify_on_completion:
          type: boolean
          default: false
          description: Whether the person who started this run asked, at the time, to be emailed once it finishes.
        notified_at:
          type: string
          format: date-time
          description: When the opt-in completion email was actually sent (absent if never sent).
    DiffRunList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/DiffRun"
        next_cursor:
          type: string
    BillingMarginEvaluation:
      type: object
      description: Revenue is `ClosePeriod` over this one usage event, which is legitimate only because the allowance math is event-local (each event carries its own delta plus the lifecycle cumulative), so per-event prices sum exactly to the period's.
      required:
        - evaluation_id
        - priced_revenue_micro
        - llm_cost_micro
        - llm_call_count
        - infra_overhead_micro
        - margin_micro
        - cost_attributed
      properties:
        evaluation_id:
          type: string
          format: uuid
        priced_revenue_micro:
          type: integer
          format: int64
        llm_cost_micro:
          type: integer
          format: int64
        llm_call_count:
          type: integer
          format: int64
        infra_overhead_micro:
          type: integer
          format: int64
        margin_micro:
          type: integer
          format: int64
        cost_attributed:
          type: boolean
          description: False when the llm_calls ledger holds no rows for this evaluation — a canned or replayed run, or one predating the ledger. Its margin then reads as revenue with no cost and must NOT be trusted; the flag exists so a surface can say so instead of showing 100%.
    BillingMarginScenario:
      type: object
      description: One scenario's margin — the lifecycle grain, i.e. the per-scenario margin the pricing conversation is actually about.
      required:
        - scenario_id
        - priced_revenue_micro
        - llm_cost_micro
        - llm_call_count
        - infra_overhead_micro
        - cost_micro
        - margin_micro
        - evaluations
      properties:
        scenario_id:
          type: string
          format: uuid
        priced_revenue_micro:
          type: integer
          format: int64
        llm_cost_micro:
          type: integer
          format: int64
        llm_call_count:
          type: integer
          format: int64
        infra_overhead_micro:
          type: integer
          format: int64
        cost_micro:
          type: integer
          format: int64
        margin_micro:
          type: integer
          format: int64
        evaluations:
          type: array
          items:
            $ref: "#/components/schemas/BillingMarginEvaluation"
    BillingMargin:
      type: object
      description: One tenant's gross margin for one period (PLAN). Amounts are exact integer micro-USD; the `_usd` fields are the same numbers formatted for a human and are never fed back into arithmetic.
      required:
        - tenant_id
        - period
        - currency
        - priced_revenue_micro
        - invoiced_revenue_micro
        - invoice_count
        - attributed_cost_micro
        - unattributed_cost_micro
        - infra_overhead_micro
        - total_cost_micro
        - margin_micro
        - margin_ratio
        - revenue_is_zero
        - has_contract
        - priced_revenue_usd
        - total_cost_usd
        - margin_usd
        - evaluations
        - scenarios
        - assumptions
      properties:
        tenant_id:
          type: string
          format: uuid
        period:
          type: string
          description: The period as YYYYMM — the same token the invoice reference carries.
        currency:
          type: string
        priced_revenue_micro:
          type: integer
          format: int64
          description: ClosePeriod over the whole period — the same number the invoice for this period totals.
        invoiced_revenue_micro:
          type: integer
          format: int64
          description: The actuals overlay from invoice_records (voided invoices excluded). Differs from priced revenue legitimately when the period is not yet closed, or when a minimum true-up or setup fee appears only on the invoice.
        invoice_count:
          type: integer
          format: int64
        attributed_cost_micro:
          type: integer
          format: int64
          description: Sum of llm_calls cost linked to the period's billed evaluations.
        unattributed_cost_micro:
          type: integer
          format: int64
          description: The tenant's remaining ledger spend in the period — failed evaluations, generation runs, canceled work. Real cost that earned no revenue, reported rather than omitted, because a dashboard showing only attributed cost would show a healthy margin on a deployment burning money on failures.
        infra_overhead_micro:
          type: integer
          format: int64
          description: An UNMEASURED PLACEHOLDER per evaluation (carried over unit-economics work, which measured the LLM floor and explicitly left this line open). Always named in `assumptions`.
        total_cost_micro:
          type: integer
          format: int64
        margin_micro:
          type: integer
          format: int64
        margin_ratio:
          type: number
          format: double
          description: margin_micro / priced_revenue_micro, or 0 when there is no revenue — see revenue_is_zero.
        revenue_is_zero:
          type: boolean
          description: True when the period had no priced revenue, so a margin_ratio of 0 means "no revenue" and not "break-even".
        has_contract:
          type: boolean
          description: False when the tenant has no active billing contract. Revenue is then zero by ABSENCE OF TERMS rather than by pricing, which is a contract gap (see the missing_contract reconciliation finding), not a pricing bug.
        priced_revenue_usd:
          type: number
          format: double
        total_cost_usd:
          type: number
          format: double
        margin_usd:
          type: number
          format: double
        evaluations:
          type: integer
        scenarios:
          type: array
          items:
            $ref: "#/components/schemas/BillingMarginScenario"
        assumptions:
          type: array
          description: The caveats this number rests on, in plain sentences. Never empty while the infra line is a placeholder requires it be rendered as an explicit assumption.
          items:
            type: string
    BillingReconciliationFinding:
      type: object
      description: One open finding from the reconciliation report's landing zone. `kind` is a closed vocabulary an operator can alert on; `reason` names the specific detector that fired, so a sharper detector never grows the alertable vocabulary.
      required:
        - id
        - kind
        - severity
        - created_at
        - details
      properties:
        id:
          type: string
          format: uuid
        kind:
          type: string
          enum:
            - invoice_drift
            - unpushed_backlog
            - ledger_drift
            - missing_contract
            - autopay_failed
            - unmatched_deposit
            - stale_open_invoice
            - vendor_connection_stale
        severity:
          type: string
          enum:
            - info
            - warning
            - critical
          description: A disagreement about MONEY is critical (an edited or missing invoice, unbilled usage, one event billed twice); a disagreement about TIMELINESS is a warning (an undelivered backlog, an unmatched deposit, a stale open invoice).
        reason:
          type: string
          description: The detector that fired, e.g. vendor_total_mismatch, unpriced_usage_events, open_past_grace.
        subject:
          type: string
          description: Stable identity of the thing this finding is about (`invoice:<ref>`, `deposit:<qbo payment id>`, `ledger:<tenant>:<reason>`), and the de-duplication key — the report leaves an already-open subject alone, so a nightly leg does not re-raise the same finding every night.
        tenant_id:
          type: string
          format: uuid
          description: Absent for a platform-level finding — a deposit matching no invoice, or a stale vendor connection, belongs to no tenant.
        period_start:
          type: string
          format: date
        created_at:
          type: string
          format: date-time
        details:
          type: object
          additionalProperties: true
          description: Identifiers, counts, amounts and vendor references only. Never document content.
    BillingReconciliationFindingList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/BillingReconciliationFinding"
    CreateBillingContractRequest:
      type: object
      description: The negotiated terms of a billing contract (POST /v1/tenants/{tenantId}/contracts). All money is integer micro-units of `currency`. Every field is a parameter of pricing models — the hybrid shape is a choice of numbers, not a different schema. The contract is created in `draft`; nothing here touches the payment rail.
      properties:
        currency:
          type: string
          description: The contract's ledger currency. v1 supports `usd` only; omit to default to it.
          enum:
            - usd
          example: usd
        base_price_micro:
          type: integer
          format: int64
          minimum: 0
          description: Price per scenario lifecycle, in micro-units of the currency.
          example: 25000000
        included_documents_per_scenario:
          type: integer
          format: int32
          minimum: 0
          description: The document allowance included in the base price, per scenario lifecycle.
          example: 15
        document_overage_price_micro:
          type: integer
          format: int64
          minimum: 0
          description: Price per document beyond the included allowance, in micro-units.
          example: 2000000
        monthly_minimum_micro:
          type: integer
          format: int64
          minimum: 0
          description: The monthly minimum charged even when usage falls below it, in micro-units.
          example: 500000000
        setup_fee_micro:
          type: integer
          format: int64
          minimum: 0
          description: One-time setup fee, in micro-units. Invoiced once, later, by a separate operator step.
          example: 1000000000
        question_change_rebill_threshold_bps:
          type: integer
          format: int32
          minimum: 0
          maximum: 10000
          description: The graduated rulebook-change rebill boundary in basis points (a contract parameter). Omit to inherit the platform's starting-point default.
          example: 2000
        commitment_months:
          type: integer
          format: int32
          minimum: 1
          description: Recorded commitment term in months. Omit for none.
          example: 12
        invoice_ref_prefix:
          type: string
          pattern: ^[A-Z0-9]{2,6}$
          description: The contract's short code inside the structured invoice reference UG-<PREFIX>-<YYYYMM> — 2-6 characters of A-Z/0-9, platform-unique. Optional on a draft, but REQUIRED before the contract can be activated (every invoice number derives from it).
          example: NWND
        qbo_customer_ref:
          type: string
          maxLength: 128
          description: The accounting-vendor customer this contract will invoice against, if known at provisioning time. A stored reference only — assigning it makes no vendor call. Omit until known; `PATCH` can assign it later, on a draft or on an active contract that has none, and invoices for this tenant defer until it is set.
          example: "58"
    UpdateBillingContractRequest:
      type: object
      minProperties: 1
      description: |-
        An amendment to a contract's negotiated terms (PATCH /v1/tenants/{tenantId}/contracts/{contractId}). Every field is optional and absent means "leave it alone"; at least one must be named. Every field is set-only — there is no way to clear a value back to unset here.

        WHICH OF THESE A GIVEN CONTRACT WILL ACCEPT DEPENDS ON ITS STATE, and the operation's description carries the rule in full. In short: a `draft` accepts all of them, an `active` contract accepts `qbo_customer_ref` and only to assign one it does not have, and an `ended` contract accepts none.

        WHAT IS DELIBERATELY ABSENT FROM THIS SCHEMA, so that no amendment can reach it. `status` — the lifecycle moves through its own operations (`/activate`, `/end`), never through a field. `currency` and `billing_channel` — v1 has exactly one of each. `setup_fee_invoiced_at`, `effective_at`, `ends_at` — these record what happened, and a record of what happened is not an editable field.
      properties:
        base_price_micro:
          type: integer
          format: int64
          minimum: 0
          description: Price per scenario lifecycle, in micro-units of the contract currency. Draft only.
          example: 25000000
        included_documents_per_scenario:
          type: integer
          format: int32
          minimum: 0
          description: The document allowance included in the base price, per scenario lifecycle. Draft only.
          example: 15
        document_overage_price_micro:
          type: integer
          format: int64
          minimum: 0
          description: Price per document beyond the included allowance, in micro-units. Draft only.
          example: 2000000
        monthly_minimum_micro:
          type: integer
          format: int64
          minimum: 0
          description: The monthly minimum charged even when usage falls below it, in micro-units. Draft only.
          example: 500000000
        setup_fee_micro:
          type: integer
          format: int64
          minimum: 0
          description: One-time setup fee, in micro-units. Draft only.
          example: 1000000000
        question_change_rebill_threshold_bps:
          type: integer
          format: int32
          minimum: 0
          maximum: 10000
          description: The graduated rulebook-change rebill boundary in basis points. Draft only.
          example: 2000
        commitment_months:
          type: integer
          format: int32
          minimum: 1
          description: "Recorded commitment term in months. Draft only, and set-only: a commitment recorded in error cannot be removed here."
          example: 12
        invoice_ref_prefix:
          type: string
          pattern: ^[A-Z0-9]{2,6}$
          description: The contract's short code inside the structured invoice reference UG-<PREFIX>-<YYYYMM>. Draft only — an active contract's prefix is already inside issued invoice numbers and bank memos.
          example: NWND
        qbo_customer_ref:
          type: string
          minLength: 1
          maxLength: 128
          description: |-
            The accounting-vendor customer this contract invoices against. The ONE field an active contract accepts, and only to ASSIGN one where none is recorded — re-pointing an assigned reference is a `409`. A stored reference only; setting it makes no vendor call.

            An empty string is a `422`, not a way to un-assign. This field is the only one whose stored "unset" is an empty string rather than a null, so it is the only one where a caller could otherwise have cleared a value through a surface that says it cannot. Omit the field to leave the recorded customer alone.
          example: "58"
    BillingContract:
      type: object
      description: A negotiated billing contract on a tenant. Amounts are exact integer micro-units of `currency`. This is the record-level view; the payment-rail fields an instrument would add (the vaulted method) are deliberately not surfaced here.
      required:
        - id
        - tenant_id
        - status
        - currency
        - base_price_micro
        - included_documents_per_scenario
        - document_overage_price_micro
        - monthly_minimum_micro
        - setup_fee_micro
        - question_change_rebill_threshold_bps
        - billing_channel
        - payment_collection
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        tenant_id:
          type: string
          format: uuid
        status:
          type: string
          enum:
            - draft
            - active
            - ended
          description: The contract's lifecycle state. A tenant has at most one `active` contract at a time.
        currency:
          type: string
          example: usd
        base_price_micro:
          type: integer
          format: int64
        included_documents_per_scenario:
          type: integer
          format: int32
        document_overage_price_micro:
          type: integer
          format: int64
        monthly_minimum_micro:
          type: integer
          format: int64
        setup_fee_micro:
          type: integer
          format: int64
        question_change_rebill_threshold_bps:
          type: integer
          format: int32
        commitment_months:
          type: integer
          format: int32
          description: Recorded commitment term in months; absent when none.
        invoice_ref_prefix:
          type: string
          description: The invoice-reference short code; absent on a draft that has not set one.
        qbo_customer_ref:
          type: string
          description: The accounting-vendor customer reference; absent until assigned.
        billing_channel:
          type: string
          description: Which rail invoices this contract. v1 is always `qbo_invoice`.
          enum:
            - qbo_invoice
        payment_collection:
          type: string
          enum:
            - remittance
            - online_payment
            - autopay
          description: The payment-method presentation tier. Defaults to the most conservative, `remittance`. Moving to `autopay` requires a captured method and is not done through this surface.
        setup_fee_invoiced_at:
          type: string
          format: date-time
          description: When the one-time setup fee was invoiced; absent until it has been.
        effective_at:
          type: string
          format: date-time
          description: When the contract became active; absent while `draft`.
        ends_at:
          type: string
          format: date-time
          description: When the contract ended; absent unless `ended`.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    BillingContractList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/BillingContract"
    CacheKindMetrics:
      type: object
      required:
        - cache_kind
        - hit_count
        - miss_count
        - hit_rate
        - saved_usd
      properties:
        cache_kind:
          type: string
          enum:
            - parse
            - generation
        hit_count:
          type: integer
        miss_count:
          type: integer
        hit_rate:
          type: number
          format: double
          description: hit_count / (hit_count + miss_count); 0 when both are 0.
        saved_usd:
          type: number
          format: double
          description: Real vendor spend avoided, taken from the ledger. Reported for the generation cache only; always 0 for parsing, where the saving is compute and time rather than a vendor charge.
      description: The rollup for one kind of cache.
    CacheMetricsSummary:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/CacheKindMetrics"
    EvaluationThresholds:
      type: object
      required:
        - eligibility
        - other
      properties:
        eligibility:
          type: number
          format: double
        other:
          type: number
          format: double
      description: "The confidence an answer needs before it is accepted without a person: one threshold for eligibility questions, one for everything else."
    EvaluationThresholdsPartial:
      type: object
      description: Sparse per-criticality threshold override — either field may be omitted (inherits).
      properties:
        eligibility:
          type: number
          format: double
        other:
          type: number
          format: double
    EvaluationPolicyAutoAcceptOverrides:
      type: object
      description: "auto_accept override: `thresholds` raises (or, for a tenant that also lowers `floor`, lowers) the confidence bar; `floor` is the tenant's own floor (default 0.99/0.97) — the resolved threshold always clamps UP to it."
      properties:
        thresholds:
          $ref: "#/components/schemas/EvaluationThresholdsPartial"
        floor:
          $ref: "#/components/schemas/EvaluationThresholdsPartial"
    EvaluationPolicyValidationOverrides:
      type: object
      description: Sparse calibration-validation criteria override. Any omitted field inherits the platform default.
      properties:
        min_n:
          type: integer
          description: Minimum confirmed labels required (default 200).
        held_out_frac:
          type: number
          format: double
          description: Most-recent fraction excluded from fitting (default 0.20).
        min_held_out:
          type: integer
          description: Minimum held-out sample count (default 30).
        max_ece:
          type: number
          format: double
          description: Maximum held-out ECE (default 0.05).
        alpha:
          type: number
          format: double
          description: One-sided Clopper–Pearson level (default 0.05).
        require_validated_bucket:
          type: boolean
          description: "false is the explicitly-unregulated escape: the strict N/ECE/CP gate is disabled and any fitted bucket counts as validated. The structural auto-accept path (fitted curve + unanimous + threshold + zero flags + full panel) is unchanged regardless (default true)."
    EvaluationPolicyPanelOverrides:
      type: object
      description: Sparse panel-size policy override.
      properties:
        eligibility_seats:
          type: integer
          description: Default 3.
        lazy_third:
          type: boolean
          description: Default true.
    EvaluationPolicyDecisionOverrides:
      type: object
      description: Sparse decision policy override.
      properties:
        allow_decline:
          type: boolean
          description: Default false — an eligibility-fail routes to refer, not decline.
    EvaluationPolicyBudgetOverrides:
      type: object
      description: Sparse cost budget override, expressed in USD (the wire's existing convention, e.g. ScenarioCost) though stored internally as micro-USD. 0 / omitted = unlimited.
      properties:
        scenario_soft_usd:
          type: number
          format: double
        scenario_hard_usd:
          type: number
          format: double
        estimate_usd:
          type: number
          format: double
        tenant_month_soft_usd:
          type: number
          format: double
        tenant_month_hard_usd:
          type: number
          format: double
        run_hard_usd:
          type: number
          format: double
          description: "Per-EVALUATION hard spend ceiling (packet #3): \"this ONE run must not spend more than $X\", enforced in-process mid-fan-out — a tighter, differently-scoped guard than scenario_hard_usd (which accumulates across every run of a scenario) or tenant_month_hard_usd (a rolling monthly total). 0 = no per-run ceiling. Unlike every other field here, an ABSENT value on a PUT does NOT clear it — it preserves whatever ceiling is currently armed, so a save that doesn't mention this field can never silently erase a live spend guard. Send an explicit value (including 0, to disarm) to change it."
        soft_degrade_batch:
          type: boolean
          description: Default true — a soft breach may route to provider batch APIs.
        soft_degrade_cheaper_reserves:
          type: boolean
          description: Default false — opt-in cheaper-reserve substitution on a soft breach.
    EvaluationPolicyDirectiveSupersessionOverrides:
      type: object
      description: "Sparse directive-supersession override. There is deliberately no knob for eligibility questions: a supersession that touches an eligibility question ALWAYS requires a human confirmation, structurally, and no setting here can change that."
      properties:
        max_candidates:
          type: integer
          minimum: 1
          maximum: 50
          description: How many existing questions the fast, non-AI first pass shortlists as possible targets for one instruction found in a directive document; the shortlist is then reviewed by two AI models. Higher means fewer genuine targets missed but a larger, costlier review; lower is cheaper and sharper. Default 5. Must be at least 1 — 0 would stop supersession matching finding anything at all, which looks like "nothing to review" rather than "turned off".
        auto_apply_non_eligibility:
          type: boolean
          description: Default false — confirm all. Every supersession found in a directive document waits for a human to confirm it before it changes anything. Turning this on makes NON-eligibility supersessions take effect as soon as they are found, while they are still pending review; eligibility ones still always wait.
    EvaluationPolicyDirectiveSupersessionEffective:
      type: object
      description: The fully-resolved directive-supersession policy.
      required:
        - max_candidates
        - auto_apply_non_eligibility
      properties:
        max_candidates:
          type: integer
        auto_apply_non_eligibility:
          type: boolean
    EvaluationPolicyOverrides:
      type: object
      description: The sparse per-tenant evaluation-policy override document (tenants.settings["evaluation_policy"]) — every knob group is optional; an absent group (or an absent field within a present group) inherits the platform default. PUT /v1/evaluation-policy replaces this ENTIRE document.
      properties:
        auto_accept:
          $ref: "#/components/schemas/EvaluationPolicyAutoAcceptOverrides"
        calibration_validation:
          $ref: "#/components/schemas/EvaluationPolicyValidationOverrides"
        panel:
          $ref: "#/components/schemas/EvaluationPolicyPanelOverrides"
        decision:
          $ref: "#/components/schemas/EvaluationPolicyDecisionOverrides"
        budget:
          $ref: "#/components/schemas/EvaluationPolicyBudgetOverrides"
        directive_supersession:
          $ref: "#/components/schemas/EvaluationPolicyDirectiveSupersessionOverrides"
        batch_default:
          type: boolean
          description: Whether runs default to the cheaper, slower batch path. Off by default, so runs are interactive.
    EvaluationPolicyValidationEffective:
      type: object
      description: The fully-resolved calibration-validation criteria — no pointers, every field concrete.
      required:
        - min_n
        - held_out_frac
        - min_held_out
        - max_ece
        - alpha
        - require_validated_bucket
      properties:
        min_n:
          type: integer
        held_out_frac:
          type: number
          format: double
        min_held_out:
          type: integer
        max_ece:
          type: number
          format: double
        alpha:
          type: number
          format: double
        require_validated_bucket:
          type: boolean
    EvaluationPolicyPanelEffective:
      type: object
      description: The fully-resolved panel-size policy.
      required:
        - eligibility_seats
        - lazy_third
      properties:
        eligibility_seats:
          type: integer
        lazy_third:
          type: boolean
    EvaluationPolicyDecisionEffective:
      type: object
      description: The fully-resolved decision policy.
      required:
        - allow_decline
      properties:
        allow_decline:
          type: boolean
    EvaluationPolicyBudgetEffective:
      type: object
      description: The fully-resolved cost budget, in USD. 0 = unlimited / no guard configured for that limit.
      required:
        - scenario_soft_usd
        - scenario_hard_usd
        - estimate_usd
        - tenant_month_soft_usd
        - tenant_month_hard_usd
        - run_hard_usd
        - soft_degrade_batch
        - soft_degrade_cheaper_reserves
      properties:
        scenario_soft_usd:
          type: number
          format: double
        scenario_hard_usd:
          type: number
          format: double
        estimate_usd:
          type: number
          format: double
        tenant_month_soft_usd:
          type: number
          format: double
        tenant_month_hard_usd:
          type: number
          format: double
        run_hard_usd:
          type: number
          format: double
          description: The per-EVALUATION hard spend ceiling currently in effect (0 = no per-run ceiling armed) — see EvaluationPolicyBudgetOverrides.run_hard_usd.
        soft_degrade_batch:
          type: boolean
        soft_degrade_cheaper_reserves:
          type: boolean
    EvaluationPolicyEffective:
      type: object
      description: The fully-resolved policy every evaluation flow consumes — no pointers, every knob concrete, overrides already overlaid onto the platform defaults.
      required:
        - thresholds
        - floor
        - validation
        - panel
        - decision
        - budget
        - directive_supersession
        - batch_default
      properties:
        thresholds:
          $ref: "#/components/schemas/EvaluationThresholds"
          description: The active auto-accept thresholds, already clamped up to `floor`.
        floor:
          $ref: "#/components/schemas/EvaluationThresholds"
          description: "The resolved floor `thresholds` was clamped up to — surfaced honestly: a configured threshold below this value was NOT applied as configured, it was raised to meet it."
        validation:
          $ref: "#/components/schemas/EvaluationPolicyValidationEffective"
        panel:
          $ref: "#/components/schemas/EvaluationPolicyPanelEffective"
        decision:
          $ref: "#/components/schemas/EvaluationPolicyDecisionEffective"
        budget:
          $ref: "#/components/schemas/EvaluationPolicyBudgetEffective"
        directive_supersession:
          $ref: "#/components/schemas/EvaluationPolicyDirectiveSupersessionEffective"
        batch_default:
          type: boolean
    EvaluationPolicyDocument:
      type: object
      description: GET/PUT /v1/evaluation-policy's response envelope — the stored sparse overrides alongside the fully-resolved effective policy the admin UI needs to show honest clamp/floor behavior.
      required:
        - overrides
        - effective
      properties:
        overrides:
          $ref: "#/components/schemas/EvaluationPolicyOverrides"
        effective:
          $ref: "#/components/schemas/EvaluationPolicyEffective"
    DiffItemChangeKind:
      type: string
      description: question_set_diff_items.change_kind.
      enum:
        - add
        - update
        - delete
        - source_changed_review_required
    DiffItemMatchMethod:
      type: string
      description: Which anchor-match tier resolved this item.
      enum:
        - exact_anchor
        - similarity
        - semantic_equal
        - semantic_modified
        - unmatched
    DiffItemReviewStatus:
      type: string
      description: question_set_diff_items.review_status.
      enum:
        - proposed
        - accepted
        - rejected
        - modified
        - flagged
    DiffItem:
      type: object
      description: One anchor-match proposal awaiting (or carrying) a reviewer disposition.
      required:
        - id
        - diff_run_id
        - change_kind
        - old_criterion_ids
        - new_criterion_ids
        - match_method
        - review_status
        - diff_detail
        - created_at
      properties:
        id:
          type: string
          format: uuid
        diff_run_id:
          type: string
          format: uuid
        change_kind:
          $ref: "#/components/schemas/DiffItemChangeKind"
        question_lineage_id:
          type: string
          format: uuid
          description: Absent for change_kind=add (no prior question).
        old_criterion_ids:
          type: array
          items:
            type: string
            format: uuid
        new_criterion_ids:
          type: array
          items:
            type: string
            format: uuid
        match_method:
          $ref: "#/components/schemas/DiffItemMatchMethod"
        match_confidence:
          type: number
          format: double
          description: T2 similarity ratio; absent for exact/semantic/unmatched entries.
        proposed_question:
          type: object
          additionalProperties: true
          description: The full proposed question, for an add, an update, or a review triggered by a changed source. Absent for a delete.
        diff_detail:
          type: object
          additionalProperties: true
          description: Side-by-side old/new criterion text + T3 summaries (UI).
        review_status:
          $ref: "#/components/schemas/DiffItemReviewStatus"
        reviewer_disposition:
          type: object
          additionalProperties: true
          description: The reviewer's edited body when review_status=modified.
        reviewed_by:
          type: string
          format: uuid
        reviewed_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
    DiffItemList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/DiffItem"
        next_cursor:
          type: string
    DispositionDiffItemRequest:
      type: object
      required:
        - disposition
      properties:
        disposition:
          type: string
          enum:
            - accept
            - reject
            - modify
        reviewer_content:
          type: object
          additionalProperties: true
          description: Required when disposition=modify — the reviewer's edited proposed-question body.
    AuditActorType:
      type: string
      enum:
        - user
        - api_key
        - system
        - workflow
      description: "Distinct from TenantMe's actor_type (user/api_key only — the two auth principal kinds): audit events also attribute platform/workflow-initiated mutations."
    AuditEvent:
      type: object
      description: One append-only, hash-chained audit_events row.
      required:
        - id
        - actor_type
        - action
        - resource_type
        - context
        - created_at
      properties:
        id:
          type: string
          format: uuid
        actor_type:
          $ref: "#/components/schemas/AuditActorType"
        actor_id:
          type: string
          format: uuid
          description: Absent for system/workflow actors.
        actor_display_name:
          type: string
          description: "Read-time display resolution of actor_id (rule 3 — no raw internals): the user's display name (email when the display name is blank) for user actors, the key's name for api_key actors. Absent for system/workflow actors and when the actor row no longer resolves (e.g. a deleted user). Never part of the hash-chained stored payload — enrichment happens on read; stored audit rows are immutable."
        actor_pseudonym:
          type: string
          description: A stable, opaque, one-way handle for actor_id — the SAME handle the audit export carries, computed read-time from a keyed hash of (tenant, actor). It names "who" without being the actor's real id or resolving to a person on its own; identity resolves only here, beside actor_display_name, which a purge/erasure severs. Absent for system/workflow actors and when this deployment holds no signing key. Never part of the hash-chained stored payload.
        action:
          type: string
          description: e.g. "question.created", "question_set_version.published".
        resource_type:
          type: string
          description: e.g. "question", "question_set_version", "question_set", "generation_run".
        resource_id:
          type: string
          format: uuid
          description: Absent for tenant-wide actions.
        before:
          type: object
          additionalProperties: true
          description: Prior state; absent on create.
        after:
          type: object
          additionalProperties: true
          description: New state; absent on delete.
        context:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
    AuditEventList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AuditEvent"
        next_cursor:
          type: string
    AuditExport:
      type: object
      description: The self-describing, offline-verifiable export of a tenant's audit hash chain. Everything needed to recompute the chain and detect tail truncation is in this one document.
      required:
        - format_version
        - generated_at
        - tenant_id
        - genesis_hash
        - hashing
        - chain_intact
        - public_keys
        - head
        - events
        - checkpoints
      properties:
        format_version:
          type: string
          description: Export schema identifier ("ug-audit-export/1").
        generated_at:
          type: string
          format: date-time
        tenant_id:
          type: string
          format: uuid
        genesis_hash:
          type: string
          description: Hex of the prev_hash of a tenant's first event (32 zero bytes).
        hashing:
          $ref: "#/components/schemas/AuditHashingParams"
        chain_intact:
          type: boolean
          description: Our OWN in-place verification result over the exported rows — a courtesy, never a substitute for the reader's independent re-verification per the spec.
        chain_error:
          type: string
          description: The first tamper finding when chain_intact is false; absent otherwise.
        public_keys:
          type: array
          items:
            $ref: "#/components/schemas/AuditPublicKey"
          description: The signature-verification key(s). Empty when this deployment holds no signing key (the chain still re-verifies in place, but with no external anchor).
        head:
          $ref: "#/components/schemas/AuditExportHead"
        events:
          type: array
          items:
            $ref: "#/components/schemas/AuditExportEvent"
          description: Every audit_event in chain order.
        checkpoints:
          type: array
          items:
            $ref: "#/components/schemas/AuditCheckpoint"
          description: Every recorded signed checkpoint, in chain order.
        signed_head:
          allOf:
            - $ref: "#/components/schemas/AuditCheckpoint"
          description: A checkpoint freshly signed at export time (never persisted), so every export is itself a retainable anchor. Absent when no signing key is configured.
    AuditHashingParams:
      type: object
      description: The per-row hash parameters, so the export is verifiable with no external doc.
      required:
        - algorithm
        - digest
        - canonical_format
      properties:
        algorithm:
          type: string
          description: '"sha256".'
        digest:
          type: string
          description: "Exactly what is hashed: hash = sha256( prev_hash_bytes || utf8(canonical))."
        canonical_format:
          type: string
          description: The pipe-joined field order of each event''s canonical preimage.
    AuditPublicKey:
      type: object
      description: A published verification key. key_id == first 16 hex of sha256(public_key).
      required:
        - key_id
        - algorithm
        - public_key
      properties:
        key_id:
          type: string
        algorithm:
          type: string
          description: '"ed25519".'
        public_key:
          type: string
          description: Base64-standard of the raw Ed25519 public key.
    AuditExportHead:
      type: object
      description: The chain head at export time — the high-water-mark a checkpoint attests.
      required:
        - event_count
        - head_hash
      properties:
        event_count:
          type: integer
          format: int64
        head_chain_seq:
          type: integer
          format: int64
          description: The head row''s chain_seq; absent for a legacy head or an empty chain.
        head_hash:
          type: string
          description: Hex of the head row''s hash (genesis-zero for an empty chain).
    AuditExportEvent:
      type: object
      description: "One audit_event with everything needed to recompute its hash offline: hash == sha256( hex_decode(prev_hash) || utf8(canonical))."
      required:
        - id
        - actor_type
        - action
        - resource_type
        - context
        - created_at
        - created_at_micros
        - canonical
        - prev_hash
        - hash
      properties:
        id:
          type: string
          format: uuid
        chain_seq:
          type: integer
          format: int64
          description: Per-tenant append sequence; absent for a legacy (pre-00047) row.
        actor_type:
          $ref: "#/components/schemas/AuditActorType"
        actor_id:
          type: string
          format: uuid
          description: Absent for system/workflow actors.
        actor_pseudonym:
          type: string
          description: The stable, opaque, one-way handle for actor_id — a keyed hash of (tenant, actor) that names "who" without being the real id. actor_id and the canonical preimage still carry the raw id (the chain must stay verifiable), so this field is ADDITIVE and the hash is untouched; it is the reference a minimized consumer reasons over, resolvable to a person only in-app under RLS. Absent for system/workflow actors and when no signing key is configured.
        action:
          type: string
        resource_type:
          type: string
        resource_id:
          type: string
          format: uuid
          description: Absent for tenant-wide actions.
        before:
          type: object
          additionalProperties: true
          description: Prior state (parsed, for reading); the hashed text is in canonical. Absent on create.
        after:
          type: object
          additionalProperties: true
          description: New state (parsed, for reading); the hashed text is in canonical. Absent on delete.
        context:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
        created_at_micros:
          type: integer
          format: int64
          description: created_at as integer epoch microseconds — the value inside canonical.
        canonical:
          type: string
          description: The EXACT hash preimage string (see hashing.canonical_format).
        prev_hash:
          type: string
          description: Hex of the previous event''s hash (genesis-zero for the first event).
        hash:
          type: string
          description: Hex of this event''s hash.
    AuditCheckpoint:
      type: object
      description: A signed attestation of the audit chain head — the external trust anchor for tail truncation. Returned by POST /v1/audit/checkpoints and listed in the export.
      required:
        - event_count
        - head_hash
        - created_at
        - created_at_micros
        - key_id
        - algorithm
        - signature
        - signed_bytes
      properties:
        checkpoint_id:
          type: string
          format: uuid
          description: The checkpoint''s id (the recording event''s resource_id). Absent for the export''s ephemeral signed_head.
        recorded_event_id:
          type: string
          format: uuid
          description: The recording audit_event''s id. Absent for the ephemeral signed_head.
        event_count:
          type: integer
          format: int64
          description: Total audit_events at capture — the monotonic high-water-mark.
        head_chain_seq:
          type: integer
          format: int64
          description: The head row''s chain_seq; absent for a legacy head or an empty chain.
        head_hash:
          type: string
          description: Hex of the head row''s hash.
        created_at:
          type: string
          format: date-time
        created_at_micros:
          type: integer
          format: int64
          description: The SIGNED issue time as integer epoch microseconds.
        key_id:
          type: string
          description: Identifies the signing key (matches a public_keys entry).
        algorithm:
          type: string
          description: '"ed25519".'
        signature:
          type: string
          description: Base64-standard Ed25519 signature over signed_bytes.
        signed_bytes:
          type: string
          description: Base64-standard of the EXACT canonical preimage the signature covers, so a verifier need not reconstruct it (spec documents the format for those who want to).
        signature_valid:
          type: boolean
          description: Our own re-check against the published key (courtesy; the reader re-verifies).
    LayoutBlockBBox:
      type: object
      required:
        - x0
        - y0
        - x1
        - y1
        - page_w
        - page_h
      properties:
        x0:
          type: number
        y0:
          type: number
        x1:
          type: number
        y1:
          type: number
        page_w:
          type: number
          description: The source page's width, in the same units as x0/x1.
        page_h:
          type: number
          description: The source page's height, in the same units as y0/y1.
      description: A bounding box, normalized to a top-left origin. Readers hand back several coordinate conventions; every one of them is converted to this single shape before it is stored, so a consumer only ever handles one.
    LayoutBlock:
      type: object
      description: "Block IDs are the universal citation currency: every criterion/ question cites a set of these IDs, resolving deterministically to page + bbox for the evidence viewer."
      required:
        - id
        - page_no
        - block_idx
        - block_type
        - text
        - bbox
        - reading_order
      properties:
        id:
          type: string
          format: uuid
        page_no:
          type: integer
        block_idx:
          type: integer
          description: 0-based index within page_no, in reading order.
        block_type:
          type: string
          enum:
            - heading
            - text
            - list_item
            - caption
            - table
            - cell
            - figure
            - code
            - formula
            - key_value_region
            - form
            - other
        text:
          type: string
        bbox:
          $ref: "#/components/schemas/LayoutBlockBBox"
        reading_order:
          type: integer
          description: 0-based global reading-order sequence across the whole source.
        heading_level:
          type: integer
          description: Present only when block_type is "heading" (shallowest = 0).
        parent_block_id:
          type: string
          format: uuid
          description: The nearest enclosing heading's block id (section tree), or — for block_type "cell" — the owning table's block id.
        source_byte_start:
          type: integer
          description: This block's start offset into the raw bytes of a structured source (XML, JSON, CSV) — the same byte-range vocabulary a structured fact uses. Present only for a structured document's blocks; absent, rather than 0, for a page-based one, whose citations resolve by page and box instead.
        source_byte_end:
          type: integer
          description: Exclusive end offset paired with source_byte_start; see its description.
        reading_state:
          type: string
          description: |-
            A plain string, not a wire enum — `clean` | `disputed` | `corrected` today, and a client must render an unrecognised value rather than drop the block (the `AnswerFlag.kind` precedent).
            PURELY ADDITIVE: no existing field changes type or meaning, and a client that never learns about reading checks keeps rendering exactly what it always rendered.

            `clean` — no open and no accepted proposal names this block, and the value a client should assume WHEN THE FIELD IS ABSENT (a document that was never checked, a build that predates the field, or a guideline's blocks, which are never checked). `disputed` — at least one OPEN proposal names this block: `text` is still the parser's reading and still the citation substrate, but a human has not yet confirmed it. `corrected` — a human ACCEPTED a correction and no open proposal remains; `effective_text` carries the confirmed reading.

            `disputed` WINS over `corrected` when both are true (a later check raised a new note on a block whose earlier correction was already confirmed), because open work is the more urgent fact. `effective_text` is still present in that case — see its own description.
        effective_text:
          type: string
          description: |-
            That is always the case when `reading_state` is `corrected`, and can also happen alongside `disputed`.

            `text` IS NEVER OVERWRITTEN AND NEVER CHANGES MEANING: it is always the reading the parser produced, and the reading every citation, every bbox and every content hash was computed over. A surface that shows text to a PERSON should prefer `effective_text` when present; a surface that RESOLVES CITATIONS must keep using `text`. Absent means "there is no confirmed alternative reading" — never "the reading is empty".
        reading_proposal_ids:
          type: array
          items:
            type: string
            format: uuid
          description: Every reading proposal naming this block, open or resolved, so a client can go from a block to its proposals without a second query. Read one with `GET /v1/reading-proposals/{proposalId}`. Absent when there are none.
    LayoutBlockList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/LayoutBlock"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    ResolveLayoutBlocksRequest:
      type: object
      description: The citation ids to resolve. Duplicates are collapsed — an id asked for twice resolves once and is reported once.
      required:
        - block_ids
      properties:
        block_ids:
          type: array
          minItems: 1
          maxItems: 200
          description: "Between 1 and 200 block ids. The bound is the contract, not a hint: 201 ids is a 422, never a silently truncated answer. Split a larger citation set across calls — resolution is a read and the calls are independent."
          items:
            type: string
            format: uuid
    ResolveLayoutBlocksResponse:
      type: object
      required:
        - items
        - missing_block_ids
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/LayoutBlock"
          description: The requested block_ids that exist. An id that does not exist is absent here and named in `missing_block_ids` — never a per-item 404, and never silently dropped.
        missing_block_ids:
          type: array
          items:
            type: string
            format: uuid
          description: |-
            Every requested id that does not exist in this source, in request order — an empty array when all of them resolved. A dangling citation is a real condition a client must be able to SEE (an unresolvable citation is reported, never approximated), so the server names it rather than leaving the caller to diff the requested and returned sets.

            NOT EVERY ENDPOINT USING THIS SCHEMA CAN POPULATE IT. Whether an unresolvable id is an ordinary 200 outcome or a `500` integrity fault is the resource's own contract, and each operation states it: `resolveScenarioDocumentBlocks` reports missing ids here, while `resolveGuidelineBlocks` guarantees resolvability under parse immutability and so always returns `[]`, faulting the whole call instead. Read this field as "the ids this endpoint is willing to report as missing", never as "the ids that were missing" — an empty array can mean either.
    GuidelinePage:
      type: object
      description: A rendered guideline page.
      required:
        - page_no
        - render_url
        - width
        - height
        - expires_at
      properties:
        page_no:
          type: integer
        render_url:
          type: string
          description: Presigned GET URL for the page's PNG render.
        width:
          type: integer
          description: Rendered PNG pixel width.
        height:
          type: integer
          description: Rendered PNG pixel height.
        expires_at:
          type: string
          format: date-time
    GuidelinePageList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/GuidelinePage"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    Problem:
      type: object
      description: RFC 9457 problem details; `type` is a stable `urn:ug:error:<code>` URN.
      required:
        - type
        - title
        - status
        - request_id
      properties:
        type:
          type: string
          description: Stable error URN, `urn:ug:error:<code>` — the machine-readable contract.
          examples:
            - urn:ug:error:validation
        title:
          type: string
          description: Constant per type.
        status:
          type: integer
          description: HTTP status code.
        detail:
          type: string
          description: Human-readable; may change between releases.
        instance:
          type: string
          description: Request path that produced the error.
        request_id:
          type: string
          description: Always present; matches the X-Request-Id header and logs/traces.
        errors:
          type: array
          description: Validation errors only; JSON-Pointer per item.
          items:
            type: object
            required:
              - pointer
              - message
            properties:
              pointer:
                type: string
              message:
                type: string
        retry_after_seconds:
          type: integer
          description: Present on rate-limit / budget errors.
        reason:
          type: string
          description: "Optional machine-readable sub-reason discriminating conflicts that share one taxonomy code (domain-specific extension members, documented per endpoint). Emitted today by `POST /v1/evaluations/{evaluationId}/resume` (`not_resumable` | `panel_degraded` | `budget_exhausted`), by `POST /v1/question-sets/{questionSetId}/generation-runs/{generationRunId}/resume` (`not_resumable` | `budget_exhausted`), by `POST /v1/documents/{documentId}/erase` (`parse_in_flight`), and on an ERASED record (`document_erased` / `guideline_erased` — the bytes are destroyed, so re-deriving from them is structurally impossible) by `POST.../documents/{documentId}/reparse`, by either record's `.../download` (which would otherwise presign deleted objects), by `POST.../guidelines/{guidelineId}/complete` (the guideline re-parse retry) and by `POST.../generation-runs`. by `POST.../documents/{documentId}/reparse` on an erased document (`document_erased` — the bytes are destroyed, reparse is structurally impossible), and by `POST /v1/scenarios/{scenarioId}/derive` (`parse_in_flight` | `document_erased` — the same two, meaning the same two things, on the same one-way/retryable split — plus `source_changed`, which means nothing was created and the call can be retried now). The same derive operation emits `routing_proposal_stale` on its 422s: the `routing_rule_index` the caller cited no longer names the rule the server computes, so the proposal must be re-read — or the `expected_rules_digest` you pinned no longer matches the rule set in force. It is what distinguishes those from the endpoint's OTHER 422s, which share the `validation` code and — for the program-mismatch case — the same `/program` pointer."
    NotificationChannel:
      type: string
      description: Channel-extensible notification system.
      enum:
        - email
        - webhook
    TaskNotificationSubscriptionEmailInput:
      type: object
      properties:
        address:
          type: string
          format: email
          description: Omitted -> resolved at send time to the requesting user's email.
    TaskNotificationSubscriptionWebhookInput:
      type: object
      required:
        - url
      description: An ad hoc webhook target (registered/reusable webhook-endpoint registration was removed — every webhook subscription names its own url).
      properties:
        url:
          type: string
          description: https:// only. Always signed with the tenant-default secret — there is no per-url secret.
    TaskNotificationSubscriptionInline:
      type: object
      required:
        - channel
      description: Carried on any task-creating request's optional `notifications` array — e.g. POST.../generate.../diff-runs.../questions:structure.../documents/{id}/complete.
      properties:
        channel:
          $ref: "#/components/schemas/NotificationChannel"
        email:
          $ref: "#/components/schemas/TaskNotificationSubscriptionEmailInput"
        webhook:
          $ref: "#/components/schemas/TaskNotificationSubscriptionWebhookInput"
    CreateTaskNotificationSubscriptionRequest:
      type: object
      required:
        - task_kind
        - task_id
        - channel
      description: POST /v1/task-notification-subscriptions — subscribe to an already-running task.
      properties:
        task_kind:
          type: string
          description: "One of the recognized task kinds (see ` ` for the resource each one names): `generation_run`, `diff_run`, `document_ingestion`, `guideline_ingestion`, `structure_task`, `question_set_publish`, `evaluation_run`, `report_generated`, `directive_review`. Extensible by convention, not an enforced enum — an unrecognized value is still 422 at this endpoint (there is no task row being created in the same call to anchor a typo against)."
        task_id:
          type: string
          format: uuid
        channel:
          $ref: "#/components/schemas/NotificationChannel"
        email:
          $ref: "#/components/schemas/TaskNotificationSubscriptionEmailInput"
        webhook:
          $ref: "#/components/schemas/TaskNotificationSubscriptionWebhookInput"
    TaskNotificationSubscriptionStatus:
      type: string
      enum:
        - active
        - canceled
    ApiKey:
      type: object
      description: API-key METADATA. Never the key value or its hash — `key_prefix` is the first 12 characters (the indexed lookup fragment), enough to recognise a key in a log line and not enough to authenticate with.
      required:
        - id
        - name
        - key_prefix
        - scopes
        - created_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        key_prefix:
          type: string
        scopes:
          type: array
          items:
            type: string
        created_by:
          type: string
          format: uuid
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
    ApiKeyCreated:
      type: object
      description: The created key. `key` is the full secret, shown exactly once, in this response, never again (the WebhookSecretRotated precedent).
      required:
        - key
        - api_key
      properties:
        key:
          type: string
        api_key:
          $ref: "#/components/schemas/ApiKey"
    CreateApiKeyRequest:
      type: object
      required:
        - name
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Operator-facing label, e.g. "nightly scenario import".
        environment:
          type: string
          enum:
            - live
            - test
          default: live
          description: Selects the key's prefix tag (`ug_live_` / `ug_test_`). Both authenticate identically — the tag exists so a key pasted into the wrong environment is recognisable on sight.
        scopes:
          type: array
          description: Resource-group scopes this key may exercise. Must be from the known set (see the bearerAuth description); an unknown scope is 422 rather than a key that silently authorizes nothing. Empty means a read-only key.
          items:
            type: string
        expires_at:
          type: string
          format: date-time
          description: Optional expiry. Must be in the future; authentication rejects the key from that instant on.
    ApiKeyList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ApiKey"
        next_cursor:
          type: string
    TaskNotificationSubscription:
      type: object
      required:
        - id
        - task_kind
        - task_id
        - channel
        - status
        - created_at
      properties:
        id:
          type: string
          format: uuid
        task_kind:
          type: string
        task_id:
          type: string
          format: uuid
        channel:
          $ref: "#/components/schemas/NotificationChannel"
        email:
          $ref: "#/components/schemas/TaskNotificationSubscriptionEmailInput"
        webhook:
          $ref: "#/components/schemas/TaskNotificationSubscriptionWebhookInput"
        status:
          $ref: "#/components/schemas/TaskNotificationSubscriptionStatus"
        created_at:
          type: string
          format: date-time
        canceled_at:
          type: string
          format: date-time
    TaskNotificationSubscriptionList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscription"
        next_cursor:
          type: string
    NotificationDeliveryStatus:
      type: string
      description: notification_deliveries state machine.
      enum:
        - pending
        - delivered
        - dead_lettered
    NotificationDelivery:
      type: object
      description: One delivery attempt-SET (not per HTTP/SMTP attempt — `attempts` counts sub-attempts within it).
      required:
        - id
        - subscription_id
        - event_type
        - channel
        - status
        - attempts
        - created_at
      properties:
        id:
          type: string
          format: uuid
        subscription_id:
          type: string
          format: uuid
        event_type:
          type: string
          enum:
            - task.completed
            - task.failed
        channel:
          $ref: "#/components/schemas/NotificationChannel"
        status:
          $ref: "#/components/schemas/NotificationDeliveryStatus"
        attempts:
          type: integer
        last_status_code:
          type: integer
          description: Webhook only — the last HTTP response status code.
        last_error:
          type: string
        delivered_at:
          type: string
          format: date-time
        dead_lettered_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        payload:
          type: object
          description: The rendered envelope — stored verbatim for redelivery/debugging.
    NotificationDeliveryList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/NotificationDelivery"
        next_cursor:
          type: string
    WebhookSecretRotated:
      type: object
      required:
        - secret
      description: The new secret's full value — shown exactly once, in this response, never again.
      properties:
        secret:
          type: string
        secret_last4:
          type: string
        rotated_at:
          type: string
          format: date-time
    WebhookSigningSecretMetadata:
      type: object
      required:
        - id
        - secret_last4
        - created_at
      description: Never the raw secret value.
      properties:
        id:
          type: string
          format: uuid
        secret_last4:
          type: string
        created_at:
          type: string
          format: date-time
        rotated_at:
          type: string
          format: date-time
        previous_secret_valid_until:
          type: string
          format: date-time
          description: |-
            The overlap window is deployment configuration (`overlap_window`, 24h by default), so a caller that assumed a fixed 24 hours could be wrong; this is the instant that deployment actually recorded.

            ABSENT means there is no previous secret still holding an overlap: a workspace whose secret has never been rotated, or one whose previous secret has already been destroyed by the cleanup job. A value in the PAST means the deadline has passed and the cleanup job has not run yet — the previous secret is spent either way.
    CompleteScenarioDocumentRequest:
      type: object
      description: An optional body. Use it to subscribe to notifications for this document's ingestion, so you are told when a long parse finishes.
      properties:
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscriptionInline"
    CompleteGuidelineRequest:
      type: object
      properties:
        notifications:
          type: array
          items:
            $ref: "#/components/schemas/TaskNotificationSubscriptionInline"
      description: An optional body. Use it to subscribe to notifications for this guideline's ingestion — upload a large guideline, and be emailed when it is ready.
    DocumentReadingCheck:
      type: object
      description: "One reading-check attempt over one document: the run record of a vision pass that re-read each page image against the parser's own block text. It produced PROPOSALS; it changed no stored byte of the parse."
      required:
        - document_id
        - attempt
        - status
        - pages_total
        - pages_checked
        - blocks_checked
        - proposals_open
        - started_at
      properties:
        id:
          type: string
          format: uuid
          description: The attempt's id. ABSENT when `status` is `not_applicable`, which is a derived value rather than a stored row.
        document_id:
          type: string
          format: uuid
        attempt:
          type: integer
          description: 1-based, dense, per document. A re-run appends the next number rather than replacing one. `0` accompanies `not_applicable` (no attempt exists).
        status:
          type: string
          description: |-
            A plain string, NOT a wire enum, following the same precedent `AnswerFlag.kind` and `reason` below set: the closed set lives in the database and a client must render an unrecognised value (humanised) rather than drop it. Today the set is `not_applicable` | `running` | `completed` | `skipped` | `failed`.

            `not_applicable` — no check applies to this document (see `reason`); a derived value, never a stored row. `running` — in flight; treat proposals as incomplete and show nothing as confirmed. `completed` — the pass finished; `proposals_open` proposals are waiting for a human, and zero is the ordinary good outcome. `skipped` — deliberately not checked (see `reason`). `failed` — the pass could not finish; it surfaces NO proposals, because findings from a run that broke are not evidence anyone should act on. The transition running -> completed|skipped|failed happens exactly once and sets `finished_at`.
        reason:
          type: string
          description: "Why a check is `not_applicable`, `skipped` or `failed`. A plain string, NOT a wire enum (the AnswerFlag.kind precedent): the closed set lives in the database and a client must render an unrecognised reason (humanised) rather than drop it. Today: `not_ocr_derived` (the parser did not read pixels — a born-digital PDF or a structured file) | `checker_not_configured` (this deployment has the checker off) | `too_many_pages` (past the page ceiling; `pages_total` reports the real count and NOTHING was checked, so a reviewer is never shown a partial check they might read as complete) | `no_page_images` | `model_response_invalid` | `provider_unavailable` | `customer_data_not_approved` | `call_ceiling_reached` | `internal_error`."
        pages_total:
          type: integer
          description: The document's page count, recorded even when nothing was checked.
        pages_checked:
          type: integer
          description: 'Pages the agreement rule actually ran on. Deliberately not the same as "pages attempted": a page whose samples errored was attempted, not checked, and counting it would let a run where every call failed report itself complete with zero proposals — the worst lie this feature could tell, because "no notes" reads as "no problems".'
        blocks_checked:
          type: integer
        proposals_open:
          type: integer
          description: How many proposals this attempt produced. A SNAPSHOT taken when the attempt finished — it does not decrease as humans resolve them. For the live count, filter the proposals listing on `status=open`.
        filtered:
          type: object
          additionalProperties:
            type: integer
          description: "How many machine claims the deterministic post-filter refused, keyed by reason (`no_op`, `unknown_block`, `content_added`, `structured_token_altered`, `no_agreement`, `wrong_page`, `page_over_proposed`, `model_response_invalid`). Published deliberately: it is the honest measure of how often the machine over-reaches, and an integrator building trust in this feature should be able to see it. `structured_token_altered` is the one to watch — it counts proposals refused for altering the digits or letters of an identifier, a date or an amount."
        samples:
          type: integer
          description: Model calls issued. Each page is read TWICE and a claim surfaces only if both readings agree, so this is normally 2 x `pages_checked`.
        started_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
          description: Set at the one terminal transition; absent while `running`.
        model:
          type: string
          description: PLATFORM-ONLY. Omitted for every other principal.
        provider:
          type: string
          description: PLATFORM-ONLY. Omitted for every other principal.
    ReadingProposal:
      type: object
      description: "One machine claim about one block's reading — a durable, addressable object with a globally-unique id and a single lifecycle: `open` -> `accepted` | `rejected`, once, forever. A proposal is never applied by the system; a human accepts or rejects it."
      required:
        - id
        - document_id
        - block_id
        - page_no
        - kind
        - original_text
        - status
        - created_at
      properties:
        id:
          type: string
          format: uuid
        scenario_id:
          type: string
          format: uuid
          description: The document's scenario, so a tenant-wide queue row is actionable without a second fetch.
        document_id:
          type: string
          format: uuid
        document_filename:
          type: string
          description: The document's filename, for naming the row to a human.
        check_id:
          type: string
          format: uuid
          description: The reading-check attempt that produced this proposal.
        block_id:
          type: string
          format: uuid
          description: The `LayoutBlock` this claim is about — the citation currency. Resolve it through the document's blocks endpoint to get page and bbox.
        page_no:
          type: integer
        kind:
          type: string
          description: "`correction` — a proposed replacement reading, one click from being confirmed (`proposed_text` is present). `low_confidence` — no replacement is offered: either the checker could not read the block confidently, or its proposed reading was REFUSED by the deterministic post-filter for altering a structured-looking value (see `rejected_text`). A plain string, not a wire enum (the AnswerFlag.kind precedent): render an unrecognised kind rather than dropping it."
        original_text:
          type: string
          description: The parser's reading of the block, copied at proposal time. Still exactly what `LayoutBlock.text` returns, and still what every citation resolves to.
        proposed_text:
          type: string
          description: The proposed reading. Present only for kind `correction`. Accepting makes this the block's `effective_text`; it never overwrites `text`.
        rejected_text:
          type: string
          description: A reading the checker proposed and the deterministic post-filter REFUSED to offer for one-click confirmation, because it altered the digits or letters of a structured-looking value (an identifier, a date, an amount) rather than only its spacing or case. Present only on a downgraded `low_confidence` proposal. It is context for a human reading the page image themselves; it can never be applied, and no API call will apply it.
        detail:
          type: object
          additionalProperties: true
          description: "Machine context: `reason` (the checker's own one-sentence explanation), agreement evidence (`samples`, `sample_texts_identical`) and, for a downgraded proposal, `filter_downgrade`. IDENTITY-REDACTED for non-platform principals: keys naming an AI model, provider or family are removed before serialisation. The words survive; the vendor does not. Never contains document text beyond what the dedicated text fields carry."
        status:
          type: string
          description: |-
            A plain string, not a wire enum (the `kind` precedent above): today `open` | `accepted` | `rejected`, and a client must render an unrecognised value rather than drop the row.

            `open` — awaiting a human. `accepted` — a human confirmed the proposed reading; it is now the block's `effective_text`. `rejected` — a human looked and is keeping the parser's reading. One-shot: the transition happens exactly once and cannot be reversed or edited.
        created_at:
          type: string
          format: date-time
        resolved_at:
          type: string
          format: date-time
        resolved_by_name:
          type: string
          description: The display name of the person who accepted or rejected it. Absent while the proposal is open, and once that person no longer has access.
        resolution_note:
          type: string
          description: The reviewer's optional note, as stored by the FIRST resolution. A later repeat of the same decision does not overwrite it.
    ReadingProposalList:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/ReadingProposal"
        next_cursor:
          type: string
          description: Opaque pagination cursor; absent on the last page.
    ReviewReadingProposalRequest:
      type: object
      description: A human's accept/reject decision on one reading proposal.
      required:
        - action
      properties:
        action:
          type: string
          enum:
            - accept
            - reject
          description: "`accept` — confirm the proposed reading as the block's effective reading (`correction` only; accepting a `low_confidence` proposal is refused, because there is nothing to apply). `reject` — record that a human looked and is keeping the parser's reading. Both clear the review gate."
        note:
          type: string
          maxLength: 2000
          description: Optional free text stored with the resolution. Only the first resolution's note is kept — the row is immutable once resolved.
    ReviewReadingProposalsBatchRequest:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 200
          items:
            type: object
            required:
              - proposal_id
              - action
            properties:
              proposal_id:
                type: string
                format: uuid
              action:
                type: string
                enum:
                  - accept
                  - reject
              note:
                type: string
                maxLength: 2000
    ReviewReadingProposalsBatchResult:
      type: object
      required:
        - results
      properties:
        results:
          type: array
          description: One entry per requested item, in REQUEST ORDER and of the same length as `items`.
          items:
            type: object
            required:
              - proposal_id
              - status
            properties:
              proposal_id:
                type: string
                format: uuid
              status:
                type: string
                description: |-
                  A plain string, not a wire enum — `resolved` | `already_resolved` | `not_found` | `document_erased` today.

                  `resolved` — the proposal now carries the requested resolution (this includes the safe replay of a decision already recorded). `already_resolved` — a CONTRADICTORY decision was refused; the recorded resolution stands, read it with GET /v1/reading-proposals/{proposalId}. `not_found` — no such proposal in this tenant.
              proposal:
                $ref: "#/components/schemas/ReadingProposal"
                description: Present only when `status` is `resolved`.
  responses:
    Problem:
      description: Error (RFC 9457)
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
