openapi: 3.0.3

info:
  title: riskregister.com Public REST API
  version: "1.0.0"
  description: |
    The public REST API for [riskregister.com](https://riskregister.com).

    ## Authentication

    Every request carries a Bearer token in the `Authorization`
    header. **Two token shapes are supported** at every endpoint —
    pick the one that matches your integration:

    ```
    # Organisation API key — server-to-server, org-level credential
    Authorization: Bearer rrk_live_<43 base64url chars>

    # Personal access token — per-user credential (MCP, scripts, CLI)
    Authorization: Bearer rr_pat_live_<43 base64url chars>
    ```

    **Organisation API keys** are minted by org admins at
    `/{orgSlug}/settings/api-keys`. Shown exactly once. Act on behalf of the
    org; audit rows attribute to the minting admin.

    **Personal access tokens (PATs)** are minted by individual users
    at `/account/personal-access-tokens`. Shown exactly once. Act on
    behalf of the user; audit rows attribute to the owner directly.
    PR-2 of the MCP-first pivot introduced PATs as the credential a
    desktop MCP client (Claude Code, Cursor, etc.) authenticates
    with.

    Lost tokens of either shape cannot be recovered — revoke and
    mint a new one. The two shapes are operationally interchangeable
    on every `/api/v1/*` endpoint; the server routes by the leading
    characters of the token.

    ## Tier gate

    Two per-credential gating paths apply at every endpoint:

    - **`api_access`** (Business+, required for `rrk_live_*` org
      keys). Free or Team-tier organisations receive `403 Forbidden`
      with the upgrade message even with a valid key (e.g. a key
      minted while on Business that survives a downgrade fails
      closed).
    - **`personal_access_tokens`** (every plan including **Free** by
      default, required for `rr_pat_live_*` PATs). The gate exists
      so an operator can disable PATs for a specific organisation
      via `org_feature_overrides` (incident response / abuse case)
      without changing the org's plan. A `403 Forbidden` on the PAT
      path always means the operator has explicitly disabled the
      feature — the remedy is "contact your administrator", not
      "upgrade your plan".

    Scopes / control implementations endpoints have a second-tier
    `scopes` gate (also Business+), so an operator can disable scope
    surface area without disabling the whole API.

    ## Pagination

    Every endpoint that lists one kind of record (`data` is an array)
    paginates with `?cursor=<opaque>&limit=<int>`, except three small,
    bounded lists, which return every row at once with no `pagination`
    object: the risk categories, the templates, and a workspace's
    members (narrowed with `search`, not pages). A read that gathers
    several capped lists into one object (My Work, and a register's
    review queue, treatment plan and changes) says a list was cut short
    with `truncated` instead of paging.

    - `cursor`: opaque token returned by the previous page's
      `pagination.next_cursor`. Omit on the first page. **The cursor
      body is an implementation detail and may change without a
      version bump. Treat the entire string as opaque — do not
      decode, parse, or generate cursors client-side.**
    - `limit`: rows per page. Default 50, max 100. Larger values are
      silently clamped.

    Cursors do not expire and remain valid against the underlying
    ordering. If the row a cursor anchors on is deleted between
    pages, the next page resumes correctly past the deletion point.

    Response envelope on list endpoints:

    ```json
    {
      "data": [...],
      "pagination": { "next_cursor": "<opaque>|null", "has_more": true|false },
      "requestId": "<uuid>"
    }
    ```

    `has_more: false` is the authoritative end-of-stream signal —
    `next_cursor` is `null` whenever `has_more` is `false`. Do not
    rely on `data.length === limit` — partial pages are common.

    ## Rate limits

    Two limits apply:

    - **Per-token:** 1000 requests / minute. Triggered after
      successful auth. Each token (org key OR PAT) gets its own
      independent budget — a noisy MCP client doesn't share a budget
      with the org's automation, and vice versa.
    - **Per-IP:** 60 *failed* authentication attempts / minute.
      Shared across both token shapes — an attacker rotating across
      prefix shapes from one IP cannot double their failure budget.
      Successful auth never ticks this counter, so legitimate traffic
      from a NAT'd egress IP is bounded by the per-token limit, not
      the per-IP failure budget.

    Both surface `429 Too Many Requests` with a `Retry-After: <seconds>`
    response header (RFC 6585 + RFC 9110 §10.2.3).

    No proactive `X-RateLimit-Remaining` / `X-RateLimit-Reset` headers
    are emitted on successful responses. State-changing endpoints
    (POST / PATCH / PUT / DELETE) carry a single
    `X-RateLimit-Limit-Mutations: <integer>` header advertising the
    mutation cap (currently 100 per minute per token); read endpoints
    do not emit a proactive limit header. This header is documented
    here globally rather than declared per operation (as `ETag`,
    `Location`, and `Retry-After` are) because its presence and value
    are uniform across every state-changing response — it never varies
    by operation — so a single specification-wide note is its accurate
    representation. Pace your traffic at or below
    the per-key limit; back off when you receive a 429 + Retry-After.
    Additional proactive headers (`X-RateLimit-Remaining`,
    `X-RateLimit-Reset`) may ship in a future minor version (additive).

    ## CORS

    The public API is server-to-server only. CORS headers are not
    emitted; browser-origin requests are blocked by the same-origin
    policy. Build a server-side proxy if your integration needs
    browser access.

    ## Errors

    Every error response carries:

    ```json
    {
      "error": "<human message>",
      "code": "<machine-readable code>",
      "details": { ... },
      "requestId": "<uuid>"
    }
    ```

    ## Identifiers

    User-id fields (`createdBy`, `riskOwnerId`, `controlOwnerId`,
    `ownerId`, `reviewedBy`) are internal user UUIDs. Treat them as
    opaque strings — do not parse or correlate against external
    identity systems.

    ## Versioning + deprecation

    The base path `/api/v1/` is stable for the lifetime of the v1
    contract. Breaking changes bump to `/api/v2/`. Additive changes
    (new fields, new endpoints, new optional query params) ship
    under v1 without a version bump.

    Once `/api/v2/` ships, `/api/v1/` will continue serving for at
    least 12 months. Endpoints we plan to retire emit a
    `Deprecation: true` header and a `Sunset: <RFC 1123 date>` header
    (RFC 8594) at least 6 months before removal.

  # A generated SDK carries this through to its own package metadata, so the
  # address is PUBLISHED by the time anyone reads it. `hello@` is the monitored
  # project mailbox; never an individual's.
  contact:
    name: riskregister.com
    url: https://riskregister.com/contact
    email: hello@riskregister.com

servers:
  - url: https://riskregister.com
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Workspace
    description: Who is calling, and what the workspace allows
  - name: Templates
    description: Framework templates a register can start from
  - name: Registers
    description: Risk registers and the risks within them
  - name: Proposals
    description: An agent's proposed changes to an accepted register, adopted by a person
  - name: Risks
    description: Individual risk records
  - name: Controls
    description: Org-wide control library
  - name: Implementations
    description: Per-scope control implementations (Business+ only)
  - name: Scopes
    description: Sites, systems, processes, suppliers (Business+ only)
  - name: Reference data
    description: Categories and other shared metadata
  - name: Audit log
    description: Immutable record of every state change
  - name: Webhooks
    description: Outbound HMAC-signed event delivery (Business+ only)

paths:
  /api/v1/me:
    get:
      summary: Who is calling, and what they can do here
      description: |
        The workspace context an assistant reads first:

        - the principal: the token (id, name and prefix, never its
          secret), the user it acts as, and that user's role. For an
          app connected through OAuth (`kind: oauth`, reached only
          through an MCP tool call, never at this endpoint), the
          "token" is the connection: its id, the app's name, and the
          app's OAuth client id as its prefix;
        - the workspace and its plan (and `trial`, when it is on a
          Team trial, which keeps `plan` at `free`), and every feature
          with whether this workspace has it (the plan, then any
          operator override);
        - the register and user caps with what is left (a draft counts:
          it is a register), the limits of one document save, and the
          rate limits;
        - links to the register document schema, the API reference and
          the one-call document save.
      tags: [Workspace]
      operationId: apiV1GetWorkspaceContext
      responses:
        "200":
          description: The workspace context.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/WorkspaceContext" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/me/work:
    get:
      summary: What is waiting for the person a token acts as
      description: |
        The My Work page over the API: across every register the person
        can read, four lists, each the most pressing first, at most 25
        items each (`truncated` says when there were more; `myWorkUrl` is
        the whole list):

        - `reviewsAssigned`: review requests assigned to them, the
          soonest due first;
        - `acceptancesLapsed`: risks they own whose formal acceptance has
          lapsed and not been renewed, the oldest lapse first;
        - `actionsOwned`: open or in-progress treatment actions they own,
          the soonest due first;
        - `risksOwned`: live risks they own, the soonest review first,
          then the highest residual score.

        Each item names its risk and register, says whether it is overdue
        against `asOf` (today, in UTC), and gives `url`, the page where a
        person acts on it. Accepted registers only: a draft is nobody's
        work until a person adopts it. Work in registers the person can't
        read is counted in `elsewhere`, not listed (`atLeast` when a list
        there was cut short).
      tags: [Workspace]
      operationId: apiV1GetMyWork
      responses:
        "200":
          description: The person's work.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/MyWork" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/members:
    get:
      summary: The people in the workspace
      description: |
        Each active member's `id`, name, email and organisation role, by
        name (email when a member has none), then id: what the members
        page shows every member. Use it to find the id a request that
        names someone needs: `assignedToUserId` when asking for a review,
        `riskOwnerId` for a risk's owner, `ownerUserId` for an action's.

        With `register_id`, only the people that register's work can be
        given to: its members, and the organisation's owners and admins.
        A register the caller can't read is a `403`, one that doesn't
        exist a `404`. With `search`, only those whose name or email
        contains it, ignoring case and accents ("ipek" finds İpek, "jose"
        José). No pagination: `search` narrows a
        large workspace to the person a request names. The MCP tool
        `list_members` calls this route.
      tags: [Workspace]
      operationId: apiV1ListMembers
      parameters:
        - name: register_id
          in: query
          required: false
          description: Only the people this register's work can be given to, by its UUID or its ref (`REG-0042`).
          schema:
            type: string
            example: REG-0042
        - name: search
          in: query
          required: false
          description: Only those whose name or email contains this, ignoring case and accents.
          schema:
            type: string
            maxLength: 200
            example: Sam
      responses:
        "200":
          description: The members.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Member" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/templates:
    get:
      summary: The templates a register can start from
      description: |
        The register templates the app's "Create register → From
        template" picker offers: authored registers, active, in their
        authored order. Each says what it covers, the behaviour it
        selects, how many example risks and suggested controls it
        carries, whether a named person has reviewed it, and that its
        scenarios are illustrative. Behind a token for now; opening
        template reads to anonymous callers waits for launch.
      tags: [Templates]
      operationId: apiV1ListTemplates
      responses:
        "200":
          description: The templates.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/templates/{template}:
    get:
      summary: One template, with its guide and the register document it makes
      description: |
        `{template}` is the template's slug or id; a retired or unknown
        template is a 404. The guide gives the scope, the assumptions,
        how to read the scoring scale for this kind of register, and a
        worked example review. The document is what
        `POST /api/v1/registers/from-template` instantiates: each risk
        keyed by the template's own risk key, each suggested control
        naming our library control. Adapt it and save it with
        `POST /api/v1/registers/from-document` instead, if you like.
      tags: [Templates]
      operationId: apiV1GetTemplate
      parameters:
        - name: template
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The template and its document.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/TemplateDocument" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/from-template:
    post:
      summary: Start a draft register from a template
      description: |
        The register lands as a **draft**, its risks provisional and
        keyed by the template's own risk keys, and the template's
        suggested controls adopted from our library and linked to the
        risks that name them. Give it a `key` and a document
        read back from it (`GET /api/v1/registers/{registerId}/document`)
        saves back into it; without one, a later document save makes a
        new draft. A person with register admin permission adopts it in
        the app before it counts; its webhooks wait for adoption.

        - `name` defaults to the template's name.
        - `key` names the draft for later document saves; it must be
          new in the workspace (409
          `details.subcode = API_V1_REGISTER_KEY_TAKEN` otherwise).
        - A draft counts toward the register cap.

        Same `Idempotency-Key` and body replays the original with 200;
        a different body is a 409
        (`details.subcode = API_V1_IDEMPOTENCY_BODY_MISMATCH`).
      tags: [Registers]
      operationId: apiV1CreateRegisterFromTemplate
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterFromTemplateRequest" }
      responses:
        "201":
          description: "The draft register (`replayed: false`)."
          headers:
            Location:
              description: Path of the new register.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterFromTemplate" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotency replay of an earlier request with the same key and body.
          headers:
            Location:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterFromTemplate" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers:
    get:
      summary: List registers
      description: |
        List the registers the credential can read, newest first: every
        register in the organisation for an owner's or admin's personal
        access token; the registers a member is on for a member's token.
        An organisation key reads as the person who minted it, with their
        role at the time of the request. Every read of one register's data (the
        register, its risks and their reviews, acceptances and treatment
        actions) needs read access to that register the same way, and
        answers 403 without it.
      tags: [Registers]
      operationId: apiV1ListRegisters
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list of registers
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RegisterPage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      summary: Create a register
      description: |
        Create a new risk register in the authenticated org.

        Idempotent via the optional `Idempotency-Key` header. A retry
        with the same key + same body returns the original entity
        with 200 (vs 201 on the original); same key + different body
        returns 409 (`details.subcode = API_V1_IDEMPOTENCY_BODY_MISMATCH`).

        Mutation rate limit: 100 requests / minute / key. Separate
        counter from the read limit; a single request debits either
        the read or the mutation counter, never both.
      tags: [Registers]
      operationId: apiV1CreateRegister
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateRegisterRequest" }
      responses:
        "201":
          description: Register created.
          headers:
            Location:
              description: Absolute path of the new register.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Register" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotency replay — body matches a prior request with the same key.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Register" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/registers/from-context:
    post:
      summary: Create a register from a free-form business-context paragraph (AI)
      description: |
        Generate a starter risk register and a set of initial risks
        from a free-form business-context paragraph. The response
        carries the new register AND all generated risks inline so
        an agent loop (the `@riskregister/mcp-server`
        `generate_register_from_context` tool is the primary intended
        consumer) can reason about the output without a follow-up
        `GET /api/v1/registers/{id}/risks` round trip.

        ## A draft or a live register

        What it makes follows the caller's access level. With a
        `propose` personal access token or connected app, the register
        is a **draft**: it waits for a person to review it and adopt it,
        or discard it, in the app (`reviewUrl`), and the token's owner is
        emailed that it is waiting. Until it is adopted it stays out of
        organisation-wide totals, reminders and board reports, and sends
        no webhooks. A retry with the same `Idempotency-Key` after a
        person discarded it is a 409 `API_V1_DRAFT_DISCARDED`. With
        `write`, and with an organisation key, it is live (`accepted`)
        at once. `register.state` says which. A `read` token is refused
        (403 `INSUFFICIENT_SCOPE`). The AI allowance and the register
        limit apply the same at either level.

        ## Tier gates

        - `api_access` (organisation keys `rrk_live_*`) OR
          `personal_access_tokens` (PATs `rr_pat_live_*`) — standard
          Bearer-surface gate.
        - `ai_unlimited` — Free orgs are capped at **5 AI calls per
          calendar month** across every AI surface (this endpoint plus
          the create-risk AI assist plus the import auto-categorise
          plus the in-app cookie-auth AI generation). Team+ unlocks
          unlimited. Operators may grant `ai_unlimited` to a specific
          org via `org_feature_overrides` without changing the plan.
        - `configurable_scoring_matrices` (Business+) — required when
          `scoringMatrixKind` is anything other than the default
          `iso31000_5x5`. A Team / Free caller asking for `simple_3x3`
          gets 403.

        ## Idempotency

        Same `Idempotency-Key` + same body returns the same register
        (and the same risk list) with 200 instead of 201. The AI
        quota is debited exactly once across the original + retries.
        Same key + different body returns 409 with
        `details.subcode = API_V1_IDEMPOTENCY_BODY_MISMATCH`.

        **Concurrent same-key calls**: if two requests share an
        `Idempotency-Key` and arrive while the first call's LLM call
        is in flight (8-15 s), the second receives 409 with
        `details.subcode = AI_GENERATION_IN_FLIGHT` and
        `Retry-After: 60`. The race-loser's AI quota slot is NOT
        debited. Retry after the suggested interval — once the
        original completes, the next same-key + same-body call
        replays its result with 200.

        ## Rate limits

        - The per-key mutation budget (100 req/min) ticks on every
          call. Auth failures count toward the IP failure budget.
        - An additional in-process burst guard fires at **3 AI calls
          per minute per organisation** across every AI surface. A
          legitimate agent loop should pace itself; bulk-generation
          workflows go through the regular `POST /api/v1/registers`
          + `POST /api/v1/registers/{id}/risks`.

        ## Latency

        The Anthropic call typically takes 8–15 seconds. The endpoint
        deliberately does NOT stream partial output today; the agent
        loop receives the full register + risks in a single response.

        ## Webhooks

        For a live register, customers subscribed to `register.created`
        and `risk.created` receive one and N events respectively, atomic
        with the database write (via the outbox at v0.30.0.0+). A draft
        sends nothing until a person adopts it; adoption sends the same
        events, atomic with the adoption.
      tags: [Registers]
      operationId: apiV1CreateRegisterFromContext
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateRegisterFromContextRequest" }
      responses:
        "201":
          description: Register + risks generated.
          headers:
            Location:
              description: Absolute path of the new register.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterFromContextResponse" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotency replay — body matches a prior request with the same key. The register + risks returned are the originals; no new AI call was made.
          headers:
            Location:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterFromContextResponse" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: |
            Request conflicts with current state. Three subcodes for
            this endpoint specifically:

            - `API_V1_IDEMPOTENCY_BODY_MISMATCH` — same
              `Idempotency-Key` used with a different request body.
              Change the key or send the original payload.
            - `AI_GENERATION_IN_FLIGHT` — another request with the
              same `Idempotency-Key` is running its LLM call right
              now. Response carries `Retry-After: 60` and
              `details.retryAfterSeconds: 60`. Wait then retry —
              same key + same body will replay the original
              result once the LLM call completes.
            - `API_V1_DRAFT_DISCARDED` — a retry with the same
              `Idempotency-Key` after a person discarded the draft it
              made. To generate again, send a new key.
          headers:
            Retry-After:
              description: |
                Seconds to wait before retrying. Present only on the
                `AI_GENERATION_IN_FLIGHT` subcode. RFC 6585 §4 /
                RFC 9110 §10.2.3 (integer seconds form).
              schema: { type: integer, minimum: 0 }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/registers/from-document:
    post:
      summary: Save a whole register as a draft, from a register document
      description: |
        Save a complete register in one call: the body is a
        `riskregister.com/v5` register document (its JSON Schema is
        published at `/schemas/register/v5.json`; the spec is
        `docs/reference/register-document-v5.md`). The register lands
        as a **draft**: visible and labelled in the app, left out of
        posture, reports, reminders and webhooks until a person with
        register admin permission adopts it at `reviewUrl`. Adoption is
        deliberately not available through the API.

        ## What is saved

        Every risk that has an inherent likelihood and impact, a
        category and a treatment is saved, provisional, carrying its
        `key`. A risk missing any of these is **rejected**, with its
        reasons; the rest still save. A new risk with no residual
        score takes its inherent score as its residual, crediting no
        control until one is shown to work, and `warnings` says how
        many did; an existing risk a document leaves residual out of
        keeps its own. A score stated as `unassessed` is still
        rejected.
        If every risk is rejected nothing is saved (400, reasons in
        `details.rejected`). Categories are matched by name or created;
        an owner is matched to a member by email, or kept as unresolved
        text, as is a member who isn't on the register (named in
        `warnings`). The document's controls are linked the way file import
        links them. Custom field values go to this workspace's field
        with the same name and type, for risks, the register and
        created controls. A field the workspace does not have, or a
        value that does not fit its field, is skipped and named in
        `warnings`; the risk still saves. A control the save links to
        (one the workspace already has) keeps its own values. Rationale
        and sources are not stored yet, and `warnings` says when a
        document carries them.

        Limits: 1 MiB and 200 risks per call. Import a larger register
        from a file.

        ## Register key: saving again

        `register.key` names the draft for later saves. Saving a
        document whose key already names a draft **merges** into that
        draft (`mode: merge`, 200) instead of making another:

        - each risk names a risk in the draft by its `id` when that
          is one of the draft's risks (a document read back from the
          draft carries them), else by its `key`; two risks naming the
          same one: the second is rejected;
        - a risk already in the draft updates the fields the document
          sets and that changed (`outcome: updated`), and changes
          nothing when none did (`outcome: unchanged`);
        - a new risk key adds a risk (`outcome: created`);
        - a risk the document leaves out stays as it is: a save never
          deletes;
        - a field the document leaves out stays as it is, custom
          fields included; an `owner` of `null` or `""` clears the
          owner, and a custom field value of `null` clears that value;
        - a change that puts the residual score above the inherent
          one needs the risk's `rationale` (1 to 2,000 characters,
          recorded as the change's justification), and without one
          that risk is rejected and left unchanged;
        - the register's scoring matrix cannot change (409,
          `details.subcode = API_V1_REGISTER_MATRIX_IMMUTABLE`); rows
          are checked against the draft's own matrix;
        - the token's user needs write permission on the draft, and
          admin permission when the save changes its name,
          description, type, review cadence or custom field values
          (403 otherwise), as editing it through PATCH would.

        A merge overwrites a keyed risk even if someone edited it in
        the app since the last save, and adds back one they deleted:
        for the risks it names, the document wins. A key that names an adopted
        register is 409 (`details.subcode = API_V1_REGISTER_KEY_TAKEN`):
        a save never overwrites an adopted register. A key that names
        an archived or closed draft is 409 too
        (`details.subcode = API_V1_REGISTER_NOT_ACTIVE`).

        ## Idempotency

        Same `Idempotency-Key` and body replays the original outcome
        with 200 and charges nothing more. Same key, different body:
        409 with `details.subcode = API_V1_IDEMPOTENCY_BODY_MISMATCH`.

        ## Rate limits

        One mutation at entry, like every mutation. Once the document
        is validated, the rest of its cost is charged before anything
        is written: one per 25 risks plus one per 64 KiB, at most the
        whole 100-a-minute budget. If that does not fit, the call is a
        429 and nothing is written or charged beyond the entry.
      tags: [Registers]
      operationId: apiV1SaveRegisterFromDocument
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/RegisterDocumentV5"
                - type: object
                  properties:
                    risks: { type: array, maxItems: 200 }
      responses:
        "201":
          description: "Saved as a new draft (`mode: create`)."
          headers:
            Location:
              description: Path of the new register.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterDocumentSave" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: >-
            Saved into the draft the register key names (`mode: merge`),
            or an idempotency replay of an earlier save with the same
            key and body (`replayed: true`).
          headers:
            Location:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterDocumentSave" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/document:
    get:
      summary: Read a register as its register document
      description: |
        The register as a `riskregister.com/v5` document: the same
        document the JSON export writes and
        `POST /api/v1/registers/from-document` saves.

        - Each risk's `key` is the one a document save gave it, else
          its ref (or its `id`, if another risk already uses that ref
          as its key).
        - The register carries its `key` when a save gave it one, and
          its `state`: `draft` until a person adopts it.
        - `register.revision` is the register's revision, read in the
          same snapshot as the rest of the document; information only,
          like `state`, so a save ignores it.
        - Controls are keyed by their refs, and `meta.workspace` names
          this workspace, so a save links the same controls.

        Saving the document back with its register key merges into the
        same draft: a risk with no key of its own (added in the app) is
        named by its `id`. Saved back unchanged, the document changes
        nothing. Needs read permission on the register.

        `revision` reads the register as it was at that revision, always
        in one shape: the snapshot a person's decision kept (each adoption
        of a proposal keeps one), even when it is the register's revision
        now, or for the current revision with no snapshot, the register as
        it stands, rendered the same way. Owners are rendered as
        they are now (a snapshot keeps a member as a user id). `controls`
        and `customFieldDefinitions` hold only what the register uses (the
        controls its risks link to, as they were, without their custom
        field values; the fields its values use), where the document
        without `revision` lists the workspace's. Risks come newest first
        by ref. `meta.source` says it is a revision. A revision no decision kept is
        a 404 with `details.subcode` `API_V1_REVISION_NOT_RETAINED`; one
        ahead of the register's is a 400; both carry
        `details.currentRevision`.
        `GET …/revisions` lists the retained ones.
      tags: [Registers]
      operationId: apiV1GetRegisterDocument
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - name: revision
          in: query
          required: false
          description: A revision to read the register at; the current one if omitted.
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: The register document.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterDocumentV5" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/changes:
    get:
      summary: List what changed in a register after a revision
      description: |
        For an assistant that read the register at some revision and
        wants to know what has changed since, without reading it all
        again. Each register or risk changed after `since` is listed
        once, at the latest revision that changed it, oldest first, with
        the register's revision now; a risk deleted since says so. Read
        in one snapshot. More than 500 changed things come back
        `truncated`: read the document again. A `since` ahead of the
        register's revision is refused. Needs read permission on the
        register.
      tags: [Registers]
      operationId: apiV1GetRegisterChanges
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - name: since
          in: query
          required: true
          description: The revision you last read (0 for everything logged).
          schema: { type: integer, minimum: 0 }
      responses:
        "200":
          description: What changed after `since`.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterChanges" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/revisions:
    get:
      summary: List a register's retained revisions
      description: |
        The revisions a person's decision snapshotted, newest first,
        cursor-paginated: today each adoption of a proposal keeps one.
        Each item says which revision, the adopted proposal, its reason,
        who decided and when, and the hash of the register's content at
        it; not the content, which `GET …/document?revision=` renders.
        Other revisions are not retained: `GET …/changes` says what
        changed at them. Needs read permission on the register.
      tags: [Registers]
      operationId: apiV1ListRegisterRevisions
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of retained revisions.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, requestId]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RegisterRevisionSummary" }
                  pagination: { $ref: "#/components/schemas/PaginationMeta" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/revisions/{revision}:
    get:
      summary: Read one retained revision and what made it
      description: |
        One retained revision with `appliedChanges`: what the adoption
        applied, as the reviewer saw it (the register's own fields and each
        risk's, before and after; the categories and owners it named, a
        member owner by id; the outcome). The register's content at the
        revision is `GET …/document?revision=`. Read through its register
        only: another register's is a 404. A revision ahead of the
        register's is a 400; one no decision kept, the current one
        included, is a 404 with `details.subcode`
        `API_V1_REVISION_NOT_RETAINED` and `details.currentRevision`. Needs
        read permission on the register.
      tags: [Registers]
      operationId: apiV1GetRegisterRevision
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - name: revision
          in: path
          required: true
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: The revision.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterRevision" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/proposals:
    post:
      summary: Propose a change to an accepted register
      description: |
        An agent proposes; a person adopts. `document` is the proposal
        profile of `riskregister.com/v5`: every field optional, a missing
        field unchanged. A row updates the risk its `id` names in this
        register, else the risk whose client key is its `key` (never a
        ref); a row with a new key creates a risk and needs its title,
        category, inherent score and treatment. With no residual score
        it is proposed with its inherent score as its residual, and the
        proposal's `warnings` says so. A risk is closed with
        `status: closed`, never deleted; control links are only added. A
        document read back from the API can be edited and sent as one.

        `expectedRevision` is the register revision you read. It travels in
        the body only: without it the request is a 428, and `If-Match` and
        `x-rr-if-match` are refused here. Staleness is per change: if
        anything the proposal changes (a risk it updates or names by id, the
        register's own fields, a row naming by key a risk deleted since,
        whether by its client key or the ref or id a read gave it) has
        changed after `expectedRevision`, the response is a 412 listing
        those changes in `details.changes` with `details.currentRevision`,
        so you refresh only them. An edit to an unrelated risk does not
        make a proposal stale.

        Refused rows are a 422 with the per-row report in
        `details.rejected`, and nothing is stored. Setting `status:
        accepted`, or changing an accepted risk's scores, treatment or
        status, is refused: acceptance has its own flow. A proposal that
        changes nothing is refused too.

        `supersedes` replaces one of your own open proposals in the same
        register. A register holds about 50 open proposals at most, not
        counting the one being replaced: a soft cap, so two made at the same
        moment can both pass it. A proposal expires after 14 days undecided.
        Needs write permission on the register, checked before anything
        about the register is said, or admin when it changes the register's
        own fields. The Idempotency-Key is required: the same key, body and
        register return the stored proposal as it stands now (200), and the
        same key with another body or register is a 409.
      tags: [Proposals]
      operationId: apiV1CreateProposal
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - name: Idempotency-Key
          in: header
          required: true
          description: A key of 1–200 characters of `[A-Za-z0-9_-]`, unique per proposal you make.
          schema: { type: string, pattern: "^[A-Za-z0-9_-]{1,200}$" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProposalCreate" }
      responses:
        "200":
          description: A replay of a proposal already made with this key, body and register, as it stands now.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Proposal" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "201":
          description: The proposal, waiting for a decision.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Proposal" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "428": { $ref: "#/components/responses/PreconditionRequired" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    get:
      summary: List a register's proposals
      description: |
        Newest first, cursor-paginated. `state` filters: `open` (the
        default), `adopted`, `declined`, `superseded`, `expired` or `all`.
        An open proposal past its expiry reads as expired. Each is a
        summary: read one for what it changes and its document. Needs read
        permission on the register.
      tags: [Proposals]
      operationId: apiV1ListProposals
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: state
          in: query
          required: false
          schema:
            type: string
            enum: [open, adopted, declined, superseded, expired, all]
            default: open
      responses:
        "200":
          description: A page of proposals.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, requestId]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ProposalSummary" }
                  pagination: { $ref: "#/components/schemas/PaginationMeta" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/proposals/{proposalId}:
    get:
      summary: Read one proposal
      description: |
        The proposal with the document as proposed, what adopting it
        would change, and `reviewUrl`, the register's Proposals tab where
        a person adopts or declines it. Read through its register only:
        one in another register is a 404. Needs read permission on the
        register.
      tags: [Proposals]
      operationId: apiV1GetProposal
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - name: proposalId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The proposal.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/ProposalDetail" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/export:
    get:
      summary: Export every register of the workspace
      description: |
        The bundle export: NDJSON, one `riskregister.com/v5` document per
        line, each what `GET /api/v1/registers/{registerId}/document`
        returns. Drafts are included and say so in `register.state`;
        archived registers are left out. Each line validates on its own
        against the published schema.

        The set is the registers listed when the export starts (one
        archived during the download is still sent), and
        `X-Register-Count` is its size: a transfer that ends cleanly
        holds exactly that many lines. A failure after the headers are
        sent (a register deleted outright during the download, a server
        error) fails the transfer instead of ending it short, so a body
        with fewer lines is never complete: export again. Every register
        sent gets an `exported` audit entry (format `bundle`) naming the
        token.

        Organisation-wide, so admin only. Counts as one export toward the
        organisation's export rate limit; an operator can turn exports
        off (403).
      tags: [Registers]
      operationId: apiV1ExportWorkspaceRegisters
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [ndjson]
            default: ndjson
      responses:
        "200":
          description: The registers, one v5 document per line.
          headers:
            X-Register-Count:
              description: How many lines a complete transfer holds (the registers listed when the export started).
              schema: { type: integer }
            Content-Disposition:
              schema: { type: string }
          content:
            application/x-ndjson:
              schema:
                type: string
                description: One RegisterDocumentV5 per line.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/export:
    get:
      summary: Export a register as a file
      description: |
        The register as a file, the same one the app's export writes:

        - `csv`: the register's CSV, with the metadata lines the
          importer reads back (a draft adds `# state: draft`);
        - `json`: the `riskregister.com/v5` document, as
          `GET /api/v1/registers/{registerId}/document` returns it;
        - `md`: one Markdown table in the register document's field
          names, which the importer reads back. A draft says so above
          the table.

        Excel and PDF stay in the app. Needs read permission on the
        register. Every export writes an `exported` audit entry naming
        the token, and counts toward the organisation's export rate
        limit. An operator can turn exports off for an organisation
        (403).
      tags: [Registers]
      operationId: apiV1ExportRegister
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - name: format
          in: query
          required: true
          schema:
            type: string
            enum: [csv, json, md]
      responses:
        "200":
          description: The file, as an attachment.
          headers:
            Content-Disposition:
              description: '`attachment; filename="<register-slug>-<date>.<csv|json|md>"`'
              schema: { type: string }
          content:
            text/csv:
              schema: { type: string }
            application/json:
              schema: { $ref: "#/components/schemas/RegisterDocumentV5" }
            text/markdown:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/treatment-plan:
    get:
      summary: The register's treatment plan
      description: |
        As the register's Treatment tab shows it: every treatment action
        across the register's live risks, open and in-progress first by
        due date, then done and cancelled, newest first, at most 1,000
        (`truncated` says when there were more). Each names its risk, the
        risk's treatment (`treatmentDecidedAt` null when nobody has
        decided it), its owner and whether it is overdue against `asOf`
        (today, in UTC). `counts` cover every action, not only those
        listed. `gaps` are the live risks a person decided to mitigate
        with no open or in-progress action. `planUrl` is the Treatment
        tab. Needs read permission on the register.
      tags: [Registers]
      operationId: apiV1GetRegisterTreatmentPlan
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
      responses:
        "200":
          description: The treatment plan.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/TreatmentPlan" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/applicability:
    get:
      summary: The register's Statement of Applicability position, with the recorded decisions
      description: |
        For one Annex A catalogue: what the register implies, and what the
        organisation has recorded, as the Applicability page shows it and
        the workbook downloads it. Each control is
        `selected` when a control of the organisation's that covers or
        partially covers it is linked to a live risk in the register
        (`risksTreated` names them; the basis is the risk assessment),
        `unlinked` when controls are aligned but none that covers or
        partially covers it treats a live risk here (a `related` control
        can still be linked to one, in its `linkedRisks`, and never
        selects), or `no_control`. `decision` is what an owner or admin
        recorded for the organisation, which stands over the derivation;
        null means the control is still open. This endpoint only reads:
        decisions are recorded on the Applicability page (`statementUrl`).
        `implementation` is null when
        not assessed (`implementationsTracked` is false below Business).
        Summaries are riskregister.com's own words, never the standard's;
        `attributionNotice` goes with any rendering. Needs read permission
        on the register and a Team plan or above.
      tags: [Registers]
      operationId: apiV1GetRegisterApplicability
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - name: framework
          in: query
          required: false
          description: The catalogue. Only these two ask for a Statement of Applicability.
          schema:
            type: string
            enum: [iso-27001-2022, iso-42001-2023]
            default: iso-27001-2022
      responses:
        "200":
          description: The applicability position.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Applicability" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}/review-queue:
    get:
      summary: What in this register is waiting for a person
      description: |
        Six sections, each the most pressing first, at most 100 items
        each (`truncated` says when there were more):

        - `overdue`: open or under-review risks whose review date has
          passed (in UTC, as the review reminders count it);
        - `dueSoon`: the same, due today or within 30 days;
        - `reviewsPending`: review requests not yet completed or
          cancelled;
        - `acceptancesLapsed`: accepted risks whose formal acceptance
          has lapsed and not been renewed;
        - `unresolvedOwners`: risks not closed whose owner is text no
          member matched, waiting for someone to assign one;
        - `proposalsOpen`: agents' proposals waiting for a decision, the
          soonest to lapse first, each a summary (read one at
          `…/proposals/{proposalId}`).

        The register's `state` says when it is a draft: nobody works on
        a draft until a person adopts it at `reviewUrl`. Needs read
        permission on the register.
      tags: [Registers]
      operationId: apiV1GetRegisterReviewQueue
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
      responses:
        "200":
          description: The review queue.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/ReviewQueue" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
  /api/v1/registers/{registerId}:
    get:
      summary: Fetch a register
      tags: [Registers]
      operationId: apiV1GetRegister
      description: |
        The register and its `revision`, read in one snapshot. The
        revision goes up once for each change to the register's content
        (its own fields, its risks, their control links and custom field
        values); `GET /api/v1/registers/{registerId}/changes?since=` lists
        what changed after a revision.
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
      responses:
        "200":
          description: Single register, with its revision
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterWithRevision" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      summary: Update register
      description: |
        Partial update — only supplied fields are touched. The
        scoring matrix and slug are immutable post-creation; they
        cannot be patched. Permission: the API key creator's
        register-level role must be `admin` (or org owner / admin
        gets implicit register admin).
      tags: [Registers]
      operationId: apiV1UpdateRegister
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateRegisterRequest" }
      responses:
        "200":
          description: Register updated (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Register" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      summary: Not supported — use POST /archive
      description: |
        DELETE on this endpoint returns **405 Method Not Allowed**.
        Conflating archive (soft-delete) and hard-delete here would
        forbid us from adding a true cascade-delete in the future.
        Use `POST /api/v1/registers/{registerId}/archive` to archive.
      tags: [Registers]
      operationId: apiV1DeleteRegisterMethodNotAllowed
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
      responses:
        "405": { $ref: "#/components/responses/MethodNotAllowed" }

  /api/v1/registers/{registerId}/archive:
    post:
      summary: Archive (soft-delete) a register
      description: |
        Sets the register's status to `archived`. Child risks survive;
        the register remains queryable by id but disappears from
        active listings in the UI. Permission: register admin.
      tags: [Registers]
      operationId: apiV1ArchiveRegister
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Register archived (or already archived; idempotent).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Register" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/registers/{registerId}/appetite:
    put:
      summary: Set or clear the register's risk-appetite line
      description: |
        Sets (or clears, with a `null` threshold) the register's risk
        appetite — the residual-score line a risk breaches, and an
        "approaching" margin below it. A risk's appetite state
        (`within` / `approaching` / `breach`) is derived from these
        values and the risk's residual score. Appetite is available on
        every plan. Permission: register `admin` (or org owner / admin
        gets implicit register admin). A `null` threshold clears the
        margin too; an omitted margin defaults to a band-step below the
        threshold; a margin greater than or equal to the threshold, or a
        threshold above the register matrix's maximum score, is a 400.
      tags: [Registers]
      operationId: apiV1SetRegisterAppetite
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetAppetiteRequest" }
      responses:
        "200":
          description: Appetite set (or idempotency replay). Returns the register.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Register" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/registers/{registerId}/risks:
    get:
      summary: List risks in a register
      description: |
        Paginated list of risks within one register, newest first.
        Returns `404` if the register doesn't exist or isn't visible
        to the caller.

        Optional filter query parameters narrow the result set —
        every filter is ANDed across fields, multi-valued fields
        use comma-separated values OR'd within the field. Cursor
        pagination preserves the filter scope across pages; clients
        treating the cursor as opaque (the documented contract)
        can safely re-use it. Tampering with the filter set on a
        subsequent page is undefined behaviour but never returns
        cross-tenant data (every filter runs inside the RLS-bounded
        transaction).
      tags: [Risks, Registers]
      operationId: apiV1ListRegisterRisks
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/RiskFilterStatus"
        - $ref: "#/components/parameters/RiskFilterCategoryId"
        - $ref: "#/components/parameters/RiskFilterScoreBand"
        - $ref: "#/components/parameters/RiskFilterScoreType"
        - $ref: "#/components/parameters/RiskFilterOwnerId"
        - $ref: "#/components/parameters/RiskFilterReviewStatus"
        - $ref: "#/components/parameters/RiskFilterSearch"
        - $ref: "#/components/parameters/RiskSortColumn"
        - $ref: "#/components/parameters/RiskSortDirection"
      responses:
        "200":
          description: Paginated list of risks
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RiskPage"
        "400":
          description: |
            Invalid request — examples include:
              * unknown `sort` column value
              * `dir` not in (`asc`, `desc`)
              * `sort` supplied together with `cursor` — the cursor
                format encodes the default `(createdAt, id)` ordering,
                so mixing the two would produce incoherent pagination.
                Fetch the first page sorted, or paginate using the
                default order.
            See `Error` schema for the response envelope.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      summary: Create a risk in a register
      description: |
        Create a risk attached to the supplied register. Optional
        `controlIds[]` link controls atomically. The L/I scores are
        bound to the parent register's scoring matrix (1–5 for
        `iso31000_5x5`, 1–3 for `simple_3x3`). Fires a `risk.created`
        webhook event when the org has webhook delivery enabled.

        A new risk is never `accepted` (`400`, subcode
        `ACCEPTANCE_REQUIRED`): accept it afterwards with
        `POST /api/v1/risks/{riskId}/accept`.

        A risk sent without a `reviewDate` is dated by the register's
        review cadence.

        Permission: register `write` or above.
      tags: [Risks, Registers]
      operationId: apiV1CreateRisk
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateRiskRequest" }
      responses:
        "201":
          description: Risk created.
          headers:
            Location: { schema: { type: string }, description: Absolute path of the new risk. }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Risk" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotency replay.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Risk" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/registers/{registerId}/coverage:
    get:
      summary: Register risk↔control coverage
      description: |
        The register's RCSA control-coverage picture: how many of its open
        risks have no mitigating control (`uncovered`), a control linked but
        none assessed effective (`under_covered`), or at least one effective
        control (`covered`) — plus the gap list (the `uncovered` +
        `under_covered` risks, highest residual score first), each with its
        linked controls and their effectiveness.

        Coverage grades effectiveness off control implementations, a
        Business+ feature — so a control on a plan without implementations
        resolves to `not_implemented` with `effectivenessTracked: false`
        ("not assessed", effectiveness untracked), distinct from
        `not_implemented` with `effectivenessTracked: true` (assessed
        ineffective). Consumers should treat the untracked case as "no
        effectiveness signal", not a failure.

        Returns `404` if the register doesn't exist or isn't visible to the
        caller. Read-only; reflects the register's own risk→control graph and
        asserts nothing about whether controls actually operate.
      tags: [Registers, Controls]
      operationId: apiV1GetRegisterCoverage
      parameters:
        - $ref: "#/components/parameters/RegisterIdPath"
      responses:
        "200":
          description: The register's risk↔control coverage
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RegisterCoverage" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks:
    get:
      summary: Search risks across registers
      description: |
        The risks the caller can read, across registers, newest first:
        every register for an organisation owner or admin, and for a
        member the registers they have been added to. An organisation
        API key reads as the person who minted it, with their role at
        the time of the request. With
        `register_id`, that register alone: one the caller can't read
        is a `403`, one that doesn't exist a `404`.

        Each risk names its register (ref, name, state and current
        revision), so a result can be cited, and a change proposed
        against the register's revision, without another read. Draft
        registers' risks are included, with `register.state: draft`:
        nobody has reviewed them yet.

        Filters are the register risk list's, ANDed across fields and
        OR'd within one. A score band follows its register's scoring
        matrix, so `score_band` needs `register_id`. The MCP tool
        `search_risks` calls this route.
      tags: [Risks]
      operationId: apiV1SearchRisks
      parameters:
        - $ref: "#/components/parameters/RegisterIdQuery"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/RiskFilterStatus"
        - $ref: "#/components/parameters/RiskFilterCategoryId"
        - $ref: "#/components/parameters/RiskFilterScoreBand"
        - $ref: "#/components/parameters/RiskFilterScoreType"
        - $ref: "#/components/parameters/RiskFilterOwnerId"
        - $ref: "#/components/parameters/RiskFilterReviewStatus"
        - $ref: "#/components/parameters/RiskFilterSearch"
      responses:
        "200":
          description: A page of risks, each with its register
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RiskSearchPage"
        "400":
          description: |
            Invalid request: a `register_id` that is neither a UUID nor
            a ref, `score_band` without `register_id`, or a filter value
            the list doesn't take. See `Error` schema for the envelope.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}:
    get:
      summary: Fetch a risk
      tags: [Risks]
      operationId: apiV1GetRisk
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
      responses:
        "200":
          description: Single risk with denormalised category metadata
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Risk" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      summary: Update a risk
      description: |
        Partial update. L/I scores are bound to the parent register's
        scoring matrix. Fires a `risk.updated` webhook event, plus
        `risk.escalated` when the residual risk band steps up
        (e.g. medium → high), only when the update changes something: a
        field value or a custom field value. A request that resends the
        risk's current values writes nothing (no audit-log row, `updatedAt`
        and the ETag unchanged) and fires no event; it still returns `200`
        with the risk.

        Reopening a closed risk (moving its `status` to a live one) that has
        no review date gives it the register's next review date from its
        review cadence, in the same update. A `reviewDate` in the request
        wins; `reviewDate: null` counts as naming none.

        `status: accepted` is refused (`400`, subcode
        `ACCEPTANCE_REQUIRED`) unless the risk already is accepted: a risk
        becomes accepted only through `POST /api/v1/risks/{riskId}/accept`.
        Moving an accepted risk's treatment off `accept`, or its status off
        `accepted`, retires its acceptance.

        Permission: register `write` or above.
      tags: [Risks]
      operationId: apiV1UpdateRisk
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateRiskRequest" }
      responses:
        "200":
          description: Risk updated (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Risk" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      summary: Delete a risk
      description: |
        Hard-delete the risk. Cascading `risk_control_mappings`
        emit explicit `'deleted'` audit rows so the immutable log
        remains complete. Permission: register `write` or above.

        **Retry semantics:** DELETE is naturally idempotent at the
        resource level (re-deleting a missing resource returns 404).
        The optional `Idempotency-Key` header is parsed for shape
        but not used to short-circuit replays; clients should treat
        404 on retry as evidence the prior DELETE succeeded.
      tags: [Risks]
      operationId: apiV1DeleteRisk
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/assess:
    post:
      summary: Assess a risk
      description: |
        Record a fresh risk assessment with a required rationale.
        Distinct from a generic `PATCH /api/v1/risks/{id}` because:

        - Emits a dedicated `'assessed'` audit-log row carrying the
          `rationale` as a first-class field (not buried in a generic
          diff). The activity feed renders "Assessed RISK-0042".
        - Emits the `risk.assessed` webhook event (not `risk.updated`)
          so subscribers can opt into the assessment stream
          specifically. When the assessment moves the residual risk
          band, `risk.escalated` / `risk.de_escalated` is emitted
          alongside it — the same escalation stream the PATCH path
          produces.
        - Requires all four L/I scores in the body — an "assessment"
          captures inherent AND residual together with a written
          justification. Partial-score updates go through `PATCH`.

        Scores are bound to the parent register's scoring matrix
        (1–5 for `iso31000_5x5`, 1–3 for `simple_3x3`); out-of-range
        values are rejected with 400.

        `x-rr-if-match` is opt-in (matches the existing PATCH pattern).
        Concurrent `/assess` calls without `x-rr-if-match` produce
        independent audit rows — every rationale is preserved in
        the audit log; only the score columns follow last-writer-wins.

        Permission: register `write` or above. Bearer auth (org
        API key or personal access token).
      tags: [Risks]
      operationId: apiV1AssessRisk
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssessRiskRequest" }
      responses:
        "200":
          description: Risk assessed (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Risk" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/treat:
    post:
      summary: Record a treatment decision
      description: |
        Record a treatment decision (`treatment` + `status`) with a
        required rationale. Distinct from a generic `PATCH` because:

        - Emits a dedicated `'treated'` audit-log row carrying the
          `rationale` as a first-class field. The activity feed
          renders "Treated RISK-0042".
        - Emits the `risk.treated` webhook event (not `risk.updated`)
          so subscribers can opt into the treatment stream
          specifically.
        - `status` is required. `treatment` records a treatment decision
          when given; left out, the call changes the status alone and
          records no decision, so closing a risk never claims anyone
          decided how to treat it.

        No state-machine restrictions on the (treatment, status)
        transition — operators sometimes reopen closed risks,
        sometimes accept-then-mitigate; the rationale field is what
        makes the decision auditable. Reopening a closed risk that has no
        review date gives it the register's next review date from its
        review cadence, as the generic `PATCH` does. One exception: a treatment never
        makes a risk `accepted`. That is the formal acceptance's to set
        (`POST /api/v1/risks/{riskId}/accept`), so `status: accepted` on a
        risk that isn't accepted returns `400` with subcode
        `ACCEPTANCE_REQUIRED`. Moving an accepted risk's treatment off
        `accept`, or its status off `accepted`, retires the acceptance.

        `x-rr-if-match` is opt-in (matches the existing PATCH pattern).
        Concurrent `/treat` calls without `x-rr-if-match` produce
        independent audit rows — every rationale is preserved; only
        the `treatment` and `status` columns follow last-writer-wins.

        Permission: register `write` or above. Bearer auth.
      tags: [Risks]
      operationId: apiV1TreatRisk
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TreatRiskRequest" }
      responses:
        "200":
          description: Treatment decision recorded (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Risk" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/request-review:
    post:
      summary: Request a review on a risk
      description: |
        Open a new review request against the risk. Creates a
        `risk_reviews` row in `status='pending'` with the supplied
        reviewer assignment, emits a `'review_requested'` audit row,
        and queues a `review.requested` webhook event.

        The state machine is **pending → completed | cancelled**, both
        terminal. No reopening — operators create a new review
        request instead. Independent reviews on the same risk are
        allowed (different aspects, different reviewers).

        Permission: register `write` or above on the risk's register
        — anyone with write can assign someone else to review.

        `Idempotency-Key` is supported; the body hash includes the
        URL's `riskId` so reusing the same key across different risks
        surfaces as `409` body-mismatch rather than replaying the
        prior risk's review row.

        Returns `201` with the new review's DTO on a fresh create;
        `200` on an idempotency replay. The `Location` header points
        at the canonical `GET /api/v1/reviews/{reviewId}` URL.
      tags: [Reviews]
      operationId: apiV1RequestRiskReview
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RequestRiskReviewRequest" }
      responses:
        "201":
          description: Review request opened.
          headers:
            Location: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Review" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotency replay (same key + same body as a prior request).
          headers:
            Location: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Review" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/complete-review:
    post:
      summary: Complete a pending review
      description: |
        Mark a pending review row as completed. Records the outcome
        (one of the `RiskReviewOutcome` enum values) plus optional notes,
        and advances the parent risk's `lastReviewedAt` (a record of when
        the risk was last reviewed). Completing does NOT by itself change
        the review-reminder schedule — those reminders are driven by the
        risk's `reviewDate`, not `lastReviewedAt`; supply `nextReviewDate`
        (below) to roll `reviewDate` forward and clear the overdue state.

        Permission: the **assigned reviewer** OR an **org admin/owner**.
        Non-assignee members hit `403`. Supplying `nextReviewDate`
        additionally requires register `write` (it edits the risk's
        `reviewDate`).

        Concurrent `/complete-review` calls against the same review row
        serialise on a per-review advisory lock; the second caller
        sees `409` with `details.subcode = 'REVIEW_NOT_PENDING'`. A
        review that's already `completed` or `cancelled` returns the
        same `409`.

        `x-rr-if-match` is opt-in against the review row's `updatedAt`.
        `Idempotency-Key` is scoped by `(orgId, key, action)` — the
        same key on `/request-review` then `/complete-review` produces
        independent audit rows.
      tags: [Reviews]
      operationId: apiV1CompleteRiskReview
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CompleteRiskReviewRequest" }
      responses:
        "200":
          description: Review completed (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Review" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/record-review:
    post:
      summary: Record a review just done ("Mark reviewed")
      description: |
        Record a review of the risk that someone has just done, with no
        request first. One completed review is written, assigned to and
        reviewed by the acting user. The risk's `lastReviewedAt` advances
        and, given a `nextReviewDate`, its `reviewDate` rolls forward. One
        `review_completed` audit row (marked `recorded`, with no
        `before`) and a `review.completed` webhook with `previous: null`
        follow.

        Permission: register `write` (and a token with `write` access):
        recording a review changes the risk's review record. A risk that
        already has a pending review returns `409` with
        `details.subcode = 'REVIEW_PENDING'` and the pending review's id:
        complete that one with `/complete-review`.

        `Idempotency-Key` replays the first recording, keyed on the
        action and the body (which includes the URL's risk).
      tags: [Reviews]
      operationId: apiV1RecordRiskReview
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecordRiskReviewRequest" }
      responses:
        "201":
          description: Review recorded.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Review" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotency replay of the first recording.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Review" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/cancel-review:
    post:
      summary: Cancel a pending review
      description: |
        Cancel a pending review row with a required reason. Same
        per-review serialisation as `/complete-review` — a second
        caller hits `409` with `details.subcode = 'REVIEW_NOT_PENDING'`.

        Permission: the **assigned reviewer**, the **requester**, OR
        an **org admin/owner**. Wider than complete because
        cancellation is the recovery path for both ends of the
        review relationship.

        The `reason` lands on the audit row's `changes.reason` as a
        first-class field AND on the `review.cancelled` webhook
        payload — saves customers from extracting it out of
        `audit_log.changes`.
      tags: [Reviews]
      operationId: apiV1CancelRiskReview
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CancelRiskReviewRequest" }
      responses:
        "200":
          description: Review cancelled (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Review" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/reviews:
    get:
      summary: List reviews for a risk
      description: |
        Paginated list of all review rows attached to one risk, across
        every lifecycle state. Optional `status` query param narrows to
        one of `pending` / `completed` / `cancelled`.

        Cursor preserves filter scope across pages. Ordered by
        `requestedAt DESC` (then `id DESC` tiebreak) so the result is a
        stable timeline newest-request-first, regardless of whether
        older reviews have since transitioned.

        Permission: any tenant member visible under RLS to the parent
        risk. The route also confirms the risk exists under RLS, so a
        cross-tenant `riskId` surfaces as `404` rather than an empty
        page.
      tags: [Reviews]
      operationId: apiV1ListReviewsForRisk
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, completed, cancelled]
          description: Restrict to one lifecycle state.
      responses:
        "200":
          description: Paginated review list.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, requestId]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Review" }
                  pagination: { $ref: "#/components/schemas/PaginationMeta" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/reviews/{reviewId}:
    get:
      summary: Fetch a single review
      description: |
        Fetch one review row by id. RLS-scoped — cross-tenant surfaces
        as `404` (info-disclosure safe). Returns a `Review` DTO with
        `ETag` derived from `updatedAt`.

        `reviewId` is UUID-only — review rows don't have stable
        human-readable refs (refs are reserved for first-class
        customer-facing entities per ADR-009 / stable-identifiers PR).
      tags: [Reviews]
      operationId: apiV1GetReview
      parameters:
        - $ref: "#/components/parameters/ReviewIdPath"
      responses:
        "200":
          description: Review row.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Review" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/treatment-actions:
    get:
      summary: List a risk's treatment plan
      description: |
        Paginated list of the plan items attached to one risk, newest
        first (`createdAt DESC`, `id DESC` tiebreak). Optional `status`
        narrows to one of `open` / `in_progress` / `done` / `cancelled`.

        The route confirms the parent risk exists under RLS first, so a
        cross-tenant `riskId` surfaces as `404` rather than an empty page
        a client would read as "no plan yet".
      tags: [Treatment actions]
      operationId: apiV1ListTreatmentActions
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          schema:
            type: string
            enum: [open, in_progress, done, cancelled]
          description: Restrict to one status.
      responses:
        "200":
          description: Paginated treatment-action list.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, requestId]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/TreatmentAction" }
                  pagination: { $ref: "#/components/schemas/PaginationMeta" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      summary: Add an item to a risk's treatment plan
      description: |
        Adds one owned, dated action to the risk's plan. Permission:
        register `write` — adding an item redefines what the plan is.

        Capped at 50 actions per risk. The cap counts EVERY row for the
        risk regardless of status, so cancelled items still consume a
        slot; delete an item that should never have existed rather than
        cancelling it.
      tags: [Treatment actions]
      operationId: apiV1CreateTreatmentAction
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateTreatmentActionRequest" }
      responses:
        "201":
          description: Action created.
          headers:
            Location:
              description: URL of the created action.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/TreatmentAction" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotent replay of a prior create.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/TreatmentAction" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/treatment-actions/{actionId}:
    get:
      summary: Fetch a single treatment action
      description: |
        RLS-scoped — cross-tenant surfaces as `404`. Returns the action
        with an `ETag` derived from `updatedAt`.

        **This is the supported way to obtain the concurrency token** for
        `PATCH` / `DELETE` / status changes. The token's encoding is an
        implementation detail and must not be reconstructed from
        `updatedAt`.

        `actionId` is UUID-only — treatment actions carry no stable
        human-readable ref (ADR-009).
      tags: [Treatment actions]
      operationId: apiV1GetTreatmentAction
      parameters:
        - $ref: "#/components/parameters/TreatmentActionIdPath"
      responses:
        "200":
          description: Treatment action.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/TreatmentAction" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      summary: Edit a treatment action's fields
      description: |
        Edits title, description, owner, due date or notes. Permission:
        register `write`. A cancelled action is immutable — reinstate it
        first via the status endpoint.

        Send at least one field. An explicit `null` clears a nullable
        field; omitting it leaves the field alone.
      tags: [Treatment actions]
      operationId: apiV1UpdateTreatmentAction
      parameters:
        - $ref: "#/components/parameters/TreatmentActionIdPath"
        - $ref: "#/components/parameters/IfMatch"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateTreatmentActionRequest" }
      responses:
        "200":
          description: Updated action.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/TreatmentAction" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      summary: Delete a treatment action
      description: |
        Hard-deletes an item that should never have existed. Permission:
        register `write`.

        Refuses a `done` or `cancelled` action — reopen it first. This is
        NOT interchangeable with cancelling: the 50-per-risk cap counts
        every row regardless of status, so a cancelled item holds its
        slot permanently. Cancel abandoned work; delete mistakes.
      tags: [Treatment actions]
      operationId: apiV1DeleteTreatmentAction
      parameters:
        - $ref: "#/components/parameters/TreatmentActionIdPath"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204": { description: Deleted. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/treatment-actions/{actionId}/status:
    post:
      summary: Change a treatment action's status
      description: |
        One endpoint for start / pause / complete / reopen / cancel /
        reinstate. Transitions follow a small state machine: `open` and
        `in_progress` move freely between each other and into `done` or
        `cancelled`; a `done` or `cancelled` action can REOPEN (to `open`
        or `in_progress`) because remediation work reopens when evidence
        fails re-test — but `done` and `cancelled` never convert directly
        into each other (`409`); reopen first.

        **Permission depends on the transition.** Moving between
        `open` / `in_progress` / `done` is progress on agreed work: the
        action's own owner may do it, as may a register writer or an org
        admin. Cancelling or reinstating changes what the plan IS and
        requires register `write`.

        `reason` is required when cancelling. Optional
        `expectedCurrentStatus` is a freshness claim — if it disagrees
        with the stored status the call fails `409` with subcode
        `TREATMENT_ACTION_STATE_CHANGED` before any work happens. The
        same `409` is raised if the action's status or owner changes
        between the permission decision and the write.
      tags: [Treatment actions]
      operationId: apiV1SetTreatmentActionStatus
      parameters:
        - $ref: "#/components/parameters/TreatmentActionIdPath"
        - $ref: "#/components/parameters/IfMatch"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetTreatmentActionStatusRequest" }
      responses:
        "200":
          description: Updated action.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/TreatmentAction" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/accept:
    post:
      summary: Formally accept a risk
      description: |
        Records a formal ISO 27001 6.1.2 risk acceptance with a rationale
        and optional expiry, supersedes any prior active acceptance, and
        sets the risk to `accepted`. Permission: register `write`.
        Accepting a risk whose residual is ABOVE the register's appetite
        line additionally requires org admin/owner (403
        `ACCEPTANCE_REQUIRES_ADMIN`).
      tags: [Risks]
      operationId: apiV1AcceptRisk
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AcceptRiskRequest" }
      responses:
        "200":
          description: The active acceptance (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RiskAcceptance" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/revoke-acceptance:
    post:
      summary: Revoke a risk's active acceptance
      description: |
        Revokes the risk's single active acceptance (409
        `ACCEPTANCE_NOT_ACTIVE` if there is none). A risk in `accepted`
        status goes back to `open` for a fresh decision; its treatment
        stays `accept`. Permission: register `write`.
      tags: [Risks]
      operationId: apiV1RevokeAcceptance
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RevokeAcceptanceRequest" }
      responses:
        "200":
          description: The revoked acceptance.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RiskAcceptance" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/acceptances:
    get:
      summary: List a risk's acceptance history
      description: |
        Paginated list of all acceptance rows for one risk, newest first,
        across every lifecycle state. Ordered by `createdAt DESC` (then
        `id DESC` tiebreak). RLS-scoped — a cross-tenant `riskId`
        surfaces as `404` rather than an empty page.
      tags: [Risks]
      operationId: apiV1ListAcceptancesForRisk
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated acceptance history.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, requestId]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RiskAcceptance" }
                  pagination: { $ref: "#/components/schemas/PaginationMeta" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/acceptances/{acceptanceId}:
    get:
      summary: Fetch a single acceptance
      description: |
        Fetch one acceptance row by id. RLS-scoped — cross-tenant surfaces
        as `404` (info-disclosure safe). `acceptanceId` is UUID-only.
      tags: [Risks]
      operationId: apiV1GetAcceptance
      parameters:
        - name: acceptanceId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Acceptance row.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RiskAcceptance" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risks/{riskId}/controls/{controlId}:
    put:
      summary: Link a control to a risk
      description: |
        Idempotent link. Re-PUTting the same (riskId, controlId) pair
        with identical fields returns 200 with the existing mapping
        (mappings are sets, not lists). Re-PUTting the pair with `notes`
        or `controlImplementationId` that DIFFER from the stored mapping
        returns 409 (subcode `MAPPING_DIFFERS_FROM_REQUEST`) — DELETE the
        link then PUT to replace it. Permission: register `write` or
        above on the risk's register.

        **Plan:** the bare link works on every plan. Supplying
        `controlImplementationId` requires the `scopes` feature
        (Business+); without it the request returns 403, including on a
        re-PUT of a mapping that is already pinned. Omit the field to
        re-PUT an existing link on any plan.
      tags: [Risks, Controls]
      operationId: apiV1LinkRiskControl
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/ControlIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateMappingRequest" }
      responses:
        "201":
          description: New mapping created.
          headers:
            Location: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RiskControlMapping" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Mapping already exists; returned as-is.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/RiskControlMapping" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      summary: Unlink a control from a risk
      description: |
        Idempotent unlink. DELETE on a (riskId, controlId) pair that
        isn't linked returns 204 — the caller's desired state ("no
        link") is already satisfied. Permission: register `write`
        or above on the risk's register.

        **Retry semantics:** DELETE is naturally idempotent at the
        resource level (re-deleting a missing mapping returns 204).
        The optional `Idempotency-Key` header is parsed for shape
        but not used to short-circuit replays; the response code is
        always 204 whether the pair was linked or already unlinked.
      tags: [Risks, Controls]
      operationId: apiV1UnlinkRiskControl
      parameters:
        - $ref: "#/components/parameters/RiskIdPath"
        - $ref: "#/components/parameters/ControlIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/controls:
    get:
      summary: List controls
      description: Paginated list of org-wide controls, newest first.
      tags: [Controls]
      operationId: apiV1ListControls
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list of controls
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlPage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      summary: Create a control
      description: |
        Controls are org-wide (not register-scoped). The same control
        can be linked to many risks across many registers.

        Idempotent via `Idempotency-Key`. No per-register permission
        gate, but control **definitions** are admins-write / members-read:
        a non-admin member's personal access token is rejected with 403;
        an admin-minted organisation key passes (matches the cookie-auth
        posture, which requires the `admin` org role). `GET` stays open to
        any member. Control **implementations** remain any-member.
      tags: [Controls]
      operationId: apiV1CreateControl
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateControlRequest" }
      responses:
        "201":
          description: Control created.
          headers:
            Location: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Control" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotency replay.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Control" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/controls/{controlId}:
    get:
      summary: Fetch a control
      tags: [Controls]
      operationId: apiV1GetControl
      parameters:
        - $ref: "#/components/parameters/ControlIdPath"
      responses:
        "200":
          description: Single control
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Control" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      summary: Update a control
      description: |
        Partial update. Fires a `control.status_changed` webhook
        event when the org has webhook delivery enabled. Idempotent
        via `Idempotency-Key`. Requires the `admin` org role
        (control definitions are admins-write / members-read; a
        non-admin member's PAT is rejected with 403). See `POST
        /api/v1/controls`.
      tags: [Controls]
      operationId: apiV1UpdateControl
      parameters:
        - $ref: "#/components/parameters/ControlIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateControlRequest" }
      responses:
        "200":
          description: Control updated (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Control" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      summary: Delete a control
      description: |
        Hard-delete the control. Returns 409 when the control is
        linked to any risks — unlink first via `DELETE /risks/{riskId}/
        controls/{controlId}`, or delete the parent risks. Cascading
        `control_implementation` rows emit `'deleted'` audit rows.

        **Retry semantics:** DELETE is naturally idempotent at the
        resource level (re-deleting a missing resource returns 404).
        The optional `Idempotency-Key` header is parsed for shape
        but not used to short-circuit replays; clients should treat
        404 on retry as evidence the prior DELETE succeeded.

        Requires the `admin` org role (a non-admin member's PAT is
        rejected with 403). See `POST /api/v1/controls`.
      tags: [Controls]
      operationId: apiV1DeleteControl
      parameters:
        - $ref: "#/components/parameters/ControlIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/controls/{controlId}/implementations:
    get:
      summary: List implementations of a control
      description: |
        Paginated list of per-scope implementations for one control.
        **Business+ only** (`scopes` feature flag).
      tags: [Implementations, Controls]
      parameters:
        - $ref: "#/components/parameters/ControlIdPath"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list of control implementations
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ControlImplementationPage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/scopes:
    get:
      summary: List scopes
      description: |
        Paginated list of scopes (sites, systems, processes, suppliers,
        data assets, personnel groups). **Business+ only**
        (`scopes` feature flag).
      tags: [Scopes]
      operationId: apiV1ListScopes
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list of scopes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScopePage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      summary: Create a scope
      description: |
        Create a scope (a site, system, process, supplier, data
        asset, or personnel group). **Business+ only**
        (`scopes` feature flag).

        Idempotent via `Idempotency-Key`. Mirrors the canonical
        v1 mutation shape. Mutation responses do **not** carry an
        `ETag` header — re-`GET` the resource for its current token.
      tags: [Scopes]
      operationId: apiV1CreateScope
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateScopeRequest" }
      responses:
        "201":
          description: Scope created.
          headers:
            Location: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Scope" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotency replay.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Scope" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/scopes/{scopeId}:
    get:
      summary: Fetch a scope
      description: |
        Single scope. **Business+ only** (`scopes` feature flag).
      tags: [Scopes]
      operationId: apiV1GetScope
      parameters:
        - $ref: "#/components/parameters/ScopeIdPath"
      responses:
        "200":
          description: Single scope
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Scope" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      summary: Update a scope
      description: |
        Partial update. Self-parent and multi-step cycles
        (A → B → A, A → B → C → A) are rejected with 400.
        **Business+ only** (`scopes` feature flag).
        Idempotent via `Idempotency-Key`. Optimistic concurrency
        via `x-rr-if-match`.
      tags: [Scopes]
      operationId: apiV1UpdateScope
      parameters:
        - $ref: "#/components/parameters/ScopeIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateScopeRequest" }
      responses:
        "200":
          description: Scope updated (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/Scope" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      summary: Delete a scope
      description: |
        Hard-delete the scope. Returns 409 when the scope has
        child scopes — reparent or delete the children first.
        Cascading `control_implementation` rows emit `'deleted'`
        audit rows. **Business+ only** (`scopes` feature flag).
        Optimistic concurrency via `x-rr-if-match`.

        **Retry semantics:** DELETE is naturally idempotent at
        the resource level (re-deleting a missing resource returns
        404). The optional `Idempotency-Key` header is parsed for
        shape but not used to short-circuit replays.
      tags: [Scopes]
      operationId: apiV1DeleteScope
      parameters:
        - $ref: "#/components/parameters/ScopeIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/control-implementations:
    post:
      summary: Create a control implementation
      description: |
        Create an implementation (evidence row pairing a control
        with a scope). **Business+ only** (`scopes` feature flag).

        Idempotent via `Idempotency-Key`. Both `controlId` and
        `scopeId` must reference entities visible to the caller's
        tenant; cross-tenant references return 404.
      tags: [Implementations]
      operationId: apiV1CreateControlImplementation
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateControlImplementationRequest" }
      responses:
        "201":
          description: Implementation created.
          headers:
            Location: { schema: { type: string } }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/ControlImplementation" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "200":
          description: Idempotency replay.
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/ControlImplementation" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/control-implementations/{implId}:
    get:
      summary: Fetch a control implementation
      description: |
        Single implementation. **Business+ only** (`scopes` feature flag).
      tags: [Implementations]
      operationId: apiV1GetControlImplementation
      parameters:
        - $ref: "#/components/parameters/ImplementationIdPath"
      responses:
        "200":
          description: Single implementation
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/ControlImplementation" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    patch:
      summary: Update a control implementation
      description: |
        Partial update. `controlId` and `scopeId` are **write-once**
        — to change the pair, delete and recreate. **Business+
        only** (`scopes` feature flag). Idempotent via
        `Idempotency-Key`. Optimistic concurrency via `x-rr-if-match`.
      tags: [Implementations]
      operationId: apiV1UpdateControlImplementation
      parameters:
        - $ref: "#/components/parameters/ImplementationIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateControlImplementationRequest" }
      responses:
        "200":
          description: Implementation updated (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/ControlImplementation" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      summary: Delete a control implementation
      description: |
        Hard-delete. Pinned `risk_control_mapping` rows have their
        `controlImplementationId` SET NULL — the abstract risk →
        control link survives. **Business+ only** (`scopes`
        feature flag). Optimistic concurrency via `x-rr-if-match`.

        **Retry semantics:** DELETE is naturally idempotent at
        the resource level. The optional `Idempotency-Key` header
        is parsed for shape but not used to short-circuit replays.
      tags: [Implementations]
      operationId: apiV1DeleteControlImplementation
      parameters:
        - $ref: "#/components/parameters/ImplementationIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
        - $ref: "#/components/parameters/IfMatch"
      responses:
        "204": { $ref: "#/components/responses/NoContent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "412": { $ref: "#/components/responses/PreconditionFailed" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/control-implementations/{implId}/review:
    post:
      summary: Mark an implementation as reviewed
      description: |
        Stamps `last_reviewed_at = now()` and `reviewed_by` to the
        Bearer key's creator. Optionally sets `review_date` (the
        next-review-by date). Lands a dedicated `'reviewed'`
        audit row distinct from `'updated'` so the activity feed
        renders reviews specifically. **Business+ only**
        (`scopes` feature flag). Idempotent via `Idempotency-Key`.

        Body is optional — an empty body or missing body proceeds
        with `reviewDate` unset.
      tags: [Implementations]
      operationId: apiV1ReviewControlImplementation
      parameters:
        - $ref: "#/components/parameters/ImplementationIdPath"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewControlImplementationRequest" }
      responses:
        "200":
          description: Implementation reviewed (or idempotency replay).
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data: { $ref: "#/components/schemas/ControlImplementation" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/risk-categories:
    get:
      summary: List risk categories
      description: |
        Every risk category visible to the caller — global defaults
        (system-seeded) plus the org's private categories. Bounded set;
        no pagination.
      tags: [Reference data]
      responses:
        "200":
          description: All visible categories
          content:
            application/json:
              schema:
                type: object
                required: [data, requestId]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/RiskCategory" }
                  requestId: { $ref: "#/components/schemas/RequestId" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/audit-log:
    get:
      summary: List audit log entries
      description: |
        Paginated list of audit log entries for the caller's org,
        newest first. Filterable by entity type, entity id, action,
        and timestamp range.

        The `changes` JSONB blob is exposed verbatim — this IS the
        audit log's purpose. Customers using this endpoint for
        compliance / SOC 2 / ISO 27001 evidence collection get the
        full diff of every state change. The same data is visible
        to org admins via the in-app activity feed; any API key
        with `api_access` is a Business+ admin credential.

        Two consequences worth understanding:

        1. **`changes` may include data that has since been deleted
           via the product UI.** The audit log is immutable;
           deleting a comment, removing a member, revoking a share
           link does NOT remove the historical row from this
           endpoint's response. That's the audit-trail contract.
        2. **`changes` shape is action-dependent.** Treat unknown
           top-level keys as opaque. The `changes.actor` field
           carries the discriminated actor record for every row
           written from v0.26.0.0 onwards; the field itself is
           contractually stable. Six discriminant variants exist:
           `user` (cookie-auth requests), `api_key` (Bearer-auth via
           `rrk_live_*`), `personal_access_token` (Bearer-auth via
           `rr_pat_live_*`, v0.43.0.0+), `oauth_client` (an assistant
           app a person connected to the MCP endpoint through OAuth,
           acting as that person: `connectionId`, `clientId`,
           `clientName`, `userId`, `access`), `system` (cron / webhook /
           share-link / inngest), and `impersonated` (superadmin
           acting as a tenant user, v0.34.0.0+). New variants land
           additively; treat unknown `type` values defensively.

        `ipAddress` and `userAgent` are stripped from every row —
        forensic-grade fields don't belong on the public API.
      tags: [Audit log]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
        - name: entityType
          in: query
          required: false
          description: Filter to one entity type.
          schema:
            type: string
            enum:
              - organisation
              - user
              - register
              - register_member
              - risk
              - risk_category
              - risk_review
              - control
              - control_implementation
              - scope
              - risk_control_mapping
              - comment
              - share_link
              - custom_field
              - custom_field_value
              - notification_log
              - api_key
              - webhook_endpoint
              - personal_access_token
              - org_invitation
              - org_domain
              - org_join_request
              - siem_endpoint
              - treatment_action
              - board_report
              - register_proposal
              - oauth_connection
        - name: entityId
          in: query
          required: false
          description: |
            Filter to one entity: a UUID, or a stable ref (e.g.
            `RISK-0042`) when combined with `entityType`. A UUID may be set
            without `entityType`, returning audit rows across every entity
            type that share it; combining it with `entityType` bounds it to
            one type (a more efficient query plan). Only the five kinds
            with refs take one, each with its own prefix: `risk`
            (`RISK-`), `control` (`CTRL-`), `scope` (`SCOPE-`),
            `control_implementation` (`IMPL-`) and `register` (`REG-`).
            A ref with any other `entityType` is a `400`.
          schema:
            oneOf:
              - type: string
                format: uuid
              - type: string
                # Both cases spelled out, not an `i` flag: JSON Schema
                # patterns carry no flags, and the route takes `risk-0042`.
                pattern: "^([Rr][Ii][Ss][Kk]|[Cc][Tt][Rr][Ll]|[Ss][Cc][Oo][Pp][Ee]|[Ii][Mm][Pp][Ll]|[Rr][Ee][Gg])-[0-9]+$"
                description: A stable ref, e.g. `RISK-0042` (either case), with its entity's `entityType`.
        - name: action
          in: query
          required: false
          description: Filter to one action.
          schema:
            type: string
            enum:
              - created
              - updated
              - deleted
              - reviewed
              - approved
              - exported
              - shared
              - imported
              - cloned
              - instantiated
              - plan.upgraded
              - plan.downgraded
              - plan.cancelled
              - ai_generated
              - ai_assisted
              - ai_categorised
              - api_key.created
              - api_key.revoked
              - webhook_endpoint.created
              - webhook_endpoint.updated
              - webhook_endpoint.revoked
              - webhook_endpoint.rotated
              - webhook_endpoint.auto_disabled
              - personal_access_token.created
              - personal_access_token.revoked
              - assessed
              - treated
              - appetite_updated
              - applicability_set
              - ai_applicability_drafted
              - risk_accepted
              - acceptance_revoked
              - acceptance_expired
              - review_requested
              - review_completed
              - review_cancelled
              - ai_generation_reserved
              - anonymised
              - invitation.created
              - invitation.revoked
              - invitation.accepted
              - organisation.updated
              - user.updated
              - notification_preference.updated
              - domain.added
              - domain.verified
              - domain.removed
              - domain.discoverability_set
              - join_request.rejected
              - join_request.approved
              - trial.started
              - trial.expired
              - audit_retention.configured
              - audit_retention.purged
              - audit_retention.blocked_by_siem
              - siem_endpoint.created
              - siem_endpoint.updated
              - siem_endpoint.rotated
              - siem_endpoint.revoked
              - siem_endpoint.auto_disabled
              - domain.auto_join_set
              - member.auto_joined
              - control_set_imported
              - ai_controls_drafted
              - ai_controls_drafted_for_risk
              - ai_confirmed
              - control_mapping.created
              - control_mapping.deleted
              - control_mapping.ratified
              - control_mapping.ratification_revoked
              - ai_controls_suggested
              - billing.payment_failed
              - treatment_action.created
              - treatment_action.updated
              - treatment_action.status_changed
              - treatment_action.deleted
              - board_report_generated
              - board_report_approved
              - ai_board_narrative_drafted
              - draft_adopted
              - proposal_created
              - proposal_superseded
              - proposal_expired
              - proposal_adopted
              - proposal_declined
              - organisation.created
              - ai_generation_decided
              - ai_controls_suggestion_declined
              - trial.cleared
              - oauth_connection.created
              - oauth_connection.access_changed
              - oauth_connection.revoked
              - oauth_connection.restored
              - personal_access_token.access_changed
        - name: since
          in: query
          required: false
          description: |
            Inclusive lower bound on `timestamp` (ISO 8601). For large
            or open-ended ranges, prefer walking the result set with the
            `cursor` parameter over widening `since`/`until`: the audit
            log is an unbounded-growth table and a wide time window is
            not directly index-backed (it sorts in memory), so cursor
            pagination has the more predictable latency at scale.
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          required: false
          description: Inclusive upper bound on `timestamp` (ISO 8601). Must be `>= since` when both are set.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Paginated list of audit entries
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditEntryPage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/audit-log/export:
    get:
      summary: Export the audit log (streaming)
      description: |
        Streams the org's audit log over a bounded date window as
        NDJSON (default) or CSV — the bulk, streamed form of
        `GET /api/v1/audit-log` for compliance evidence (SOC 2 / ISO
        27001) and forensics.

        Same admin gate and same per-row shape as the list endpoint:
        `changes` verbatim, `ipAddress` / `userAgent` stripped. Requires
        the `audit_log_export` feature (Business plan and above) and an
        admin-equivalent credential.

        - `since` is **required** — an export must be bounded.
        - `until` defaults to the request time, freezing the window so
          the stream is a stable snapshot.
        - A window with more entries than the export row cap returns
          `413`; narrow the date range and retry.

        Response body is a stream, NOT the paginated JSON envelope:
        NDJSON emits one audit entry object per line; CSV emits a header
        row followed by one row per entry, with `changes` JSON-encoded
        in the final column.
      tags: [Audit log]
      parameters:
        - name: since
          in: query
          required: true
          description: Inclusive lower bound on `timestamp` (ISO 8601). Required.
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          required: false
          description: Inclusive upper bound on `timestamp` (ISO 8601). Defaults to the request time; must be `>= since`.
          schema:
            type: string
            format: date-time
        - name: format
          in: query
          required: false
          description: Output format — `ndjson` (default) or `csv`.
          schema:
            type: string
            default: ndjson
            enum:
              - ndjson
              - csv
        - name: entityType
          in: query
          required: false
          description: Filter to one entity type. Accepts the same values as the `entityType` filter on `GET /api/v1/audit-log`.
          schema:
            type: string
        - name: entityId
          in: query
          required: false
          description: Filter to one entity — a UUID, or a stable ref (e.g. `RISK-0042`) when combined with `entityType`.
          schema:
            type: string
        - name: action
          in: query
          required: false
          description: Filter to one action. Accepts the same values as the `action` filter on `GET /api/v1/audit-log`.
          schema:
            type: string
      responses:
        "200":
          description: Streaming export of audit entries (NDJSON or CSV).
          content:
            application/x-ndjson:
              schema:
                type: string
                format: binary
            text/csv:
              schema:
                type: string
                format: binary
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/audit-retention:
    get:
      summary: Get the audit-log retention policy
      description: |
        Returns the org's audit-log retention policy. When no policy is
        configured (`configured: false`), audit history is retained
        forever (the default).

        Requires the `audit_retention` feature (Enterprise) and an
        admin-equivalent credential.
      tags: [Audit log]
      responses:
        "200":
          description: The org's current retention policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RetentionConfigEnvelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    put:
      summary: Set the audit-log retention policy
      description: |
        Creates or updates the org's audit-log retention policy. The
        purge cron deletes audit rows older than `retentionDays` only
        while `enabled` is `true` — a policy with `enabled: false` is
        configured-but-paused.

        `retentionDays` is floored at the compliance minimum (365) and
        capped at 36500; values outside that range return `400`. A
        request that doesn't change the stored policy is a no-op (still
        `200`, no audit row).

        Requires the `audit_retention` feature (Enterprise) and an
        admin-equivalent credential.
      tags: [Audit log]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SetRetentionConfigRequest"
      responses:
        "200":
          description: The updated retention policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RetentionConfigEnvelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      summary: Clear the audit-log retention policy
      description: |
        Removes the org's audit-log retention policy, reverting to the
        default (retain forever). Clearing an org with no policy is a
        no-op (still `200`, no audit row).

        Requires the `audit_retention` feature (Enterprise) and an
        admin-equivalent credential.
      tags: [Audit log]
      responses:
        "200":
          description: The cleared (default) retention policy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RetentionConfigEnvelope"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/webhook-endpoints:
    get:
      summary: List webhook endpoints
      description: |
        Paginated list of every outbound webhook endpoint
        configured for the caller's org, newest first. Includes
        revoked endpoints — the listing is the audit-trail surface;
        the UI styles them as "revoked".

        Mutations (create / update / revoke / rotate) are not yet
        exposed in v1. The cookie-authed admin UI at
        `/{orgSlug}/settings/webhooks` is the only configuration surface
        in the slice-B release; mutation endpoints land in v1
        alongside the broader public-API mutation surface.

        Tier gates: `api_access` AND `webhooks` (both Business+).
      tags: [Webhooks]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list of webhook endpoints
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointPage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/webhook-endpoints/{endpointId}:
    get:
      summary: Get a webhook endpoint
      description: |
        Fetch one webhook endpoint by id. Returns 404 if the
        endpoint isn't visible to the caller's tenant.
      tags: [Webhooks]
      parameters:
        - $ref: "#/components/parameters/EndpointIdPath"
      responses:
        "200":
          description: Webhook endpoint detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookEndpointEnvelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/webhook-endpoints/{endpointId}/deliveries:
    get:
      summary: List delivery attempts for an endpoint
      description: |
        Paginated list of HTTP delivery attempts for one webhook
        endpoint, newest first. One row per `(eventId, endpointId,
        attempt)` triple — a single event that retried 3 times
        across the same endpoint produces 3 rows.

        `payload` is exposed verbatim — customers can recompute
        HMAC-SHA256 over it (with `signature`'s `t=<timestamp>`
        prefix) and verify it matches `signature`. `responseBody`
        is truncated to 4 KiB at write time; longer bodies show
        a `… [truncated]` suffix.
      tags: [Webhooks]
      parameters:
        - $ref: "#/components/parameters/EndpointIdPath"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list of delivery attempts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveryPage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/siem-endpoints:
    get:
      summary: List SIEM streaming endpoints
      description: |
        Paginated list of the org's SIEM streaming endpoints, newest
        first (includes revoked endpoints — the listing is the audit-trail
        surface). Secret-free: the signing secret is shown only once on
        create/rotate via the cookie surface.

        Read-only in v1 — endpoints are created / rotated / revoked from
        the cookie-authed admin UI. Requires the `siem_streaming` feature
        (Enterprise) and an admin-equivalent credential.
      tags: [Audit log]
      parameters:
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list of SIEM endpoints
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SiemEndpointPage"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

  /api/v1/siem-endpoints/{endpointId}:
    get:
      summary: Get a SIEM streaming endpoint
      description: |
        Fetch one SIEM endpoint by id (secret-free). 404 if not visible to
        the caller's tenant. Requires the `siem_streaming` feature
        (Enterprise) and an admin-equivalent credential.
      tags: [Audit log]
      parameters:
        - $ref: "#/components/parameters/EndpointIdPath"
      responses:
        "200":
          description: SIEM endpoint detail
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SiemEndpointEnvelope"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      # Both token shapes share this scheme — the SDK / client picks
      # whichever it holds. The server dispatches by prefix.
      bearerFormat: rrk_live or rr_pat_live
      description: |
        Bearer-token authentication. TWO token shapes are accepted on
        every endpoint:

        * **Organisation API key** (`rrk_live_<43 base64url chars>`)
          — minted by an org admin from `/{orgSlug}/settings/api-keys`. Acts on
          behalf of the org; audit rows attribute to the minting
          admin. Requires the `api_access` feature flag (default
          Business+ only — a non-Business org receives 403 with a
          per-route gate message).

        * **Personal access token** (`rr_pat_live_<43 base64url
          chars>`) — minted by an individual user from
          `/account/personal-access-tokens`. Acts on behalf of the
          user; audit rows attribute to the owner directly. Requires
          the `personal_access_tokens` feature flag (unlocked on every
          plan by default — operators can disable per-org via
          `org_feature_overrides` for incident response, in which
          case the route returns 403). Each has an access level: `read`
          (reads only), `propose` (reads, plus a draft save, a template
          start, a proposal and a generation, which wait for a person)
          or `write` (everything its user may do, a generated register
          live at once; every token made before access levels existed).
          Its owner can change it at any time, on the token's row under
          Personal access tokens; an app connected by signing in has a
          level too, changed under Connected apps. A request beyond it is
          a 403 with subcode `INSUFFICIENT_SCOPE`. The level only narrows
          what the user's role allows.

        The two shapes are operationally interchangeable on every
        `/api/v1/*` endpoint — pick whichever credential matches your
        integration (org keys for server-to-server automation,
        personal access tokens for per-user clients including the
        MCP server). The SDK does not need to know which it is
        holding; the server routes by prefix.

        Suspended-org enforcement runs after the hash compare on
        both paths, so unauthenticated callers cannot probe org
        suspension state via response codes. Per-token rate limits
        (1000 reads/min, 100 mutations/min) are independent per
        token regardless of shape.

  parameters:
    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        Opaque pagination cursor from the previous page's
        `pagination.next_cursor`. Omit on the first page. Treat
        as a fully opaque string — the body is an implementation
        detail and may change without notice.
      schema:
        type: string
        example: ZXhhbXBsZS1jdXJzb3I
    Limit:
      name: limit
      in: query
      required: false
      description: Page size. Default 50, max 100. Larger values are silently clamped.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
    RegisterIdQuery:
      name: register_id
      in: query
      required: false
      description: Only this register's risks, by its UUID or its ref (`REG-0042`).
      schema:
        type: string
        example: REG-0042
    RegisterIdPath:
      name: registerId
      in: path
      required: true
      description: |
        Register UUID or stable reference (`REG-0042`). Both forms
        resolve to the same row; the resolver normalises case
        (`reg-0042` → `REG-0042`) and returns 404 for unknown values.
      schema:
        type: string
        pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
    RiskIdPath:
      name: riskId
      in: path
      required: true
      description: |
        Risk UUID, stable reference (`RISK-0042`), or opaque short-id
        slug (7 characters, as emitted by the app's short risk URLs).
        All forms resolve to the same row; the resolver normalises case
        and returns 404 for unknown values.
      schema:
        type: string
        pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+|[0-9a-hj-km-np-tv-zA-HJ-KM-NP-TV-Z]{7})$'
    ReviewIdPath:
      name: reviewId
      in: path
      required: true
      description: |
        Review row UUID. Review rows do not have stable human-readable
        refs — refs are reserved for first-class customer-facing
        entities per ADR-009. UUID-only.
      schema:
        type: string
        format: uuid
    TreatmentActionIdPath:
      name: actionId
      in: path
      required: true
      description: |
        Treatment-action UUID. Actions do not have stable human-readable
        refs — refs are reserved for first-class customer-facing entities
        per ADR-009. UUID-only.
      schema:
        type: string
        format: uuid
    RiskFilterStatus:
      name: status
      in: query
      required: false
      description: |
        Filter by status. Comma-separated for multi-value
        (`?status=open,under_review`). Values OR'd within the
        parameter, ANDed against other filters.
      style: form
      explode: false
      schema:
        type: array
        items:
          type: string
          enum: [open, under_review, closed, accepted]
        example: [open, under_review]
    RiskFilterCategoryId:
      name: category_id
      in: query
      required: false
      description: |
        Filter by risk category UUID. Comma-separated for
        multi-value. Categories are referenced by UUID only —
        the `RiskCategory` type doesn't have a stable ref form.
        Cross-tenant UUIDs return an empty result via RLS (never
        leaked).
      schema:
        type: string
        example: 1a8c1d8a-2c4f-4f3d-9a4f-b6f8c1d9e2a3
    RiskFilterScoreBand:
      name: score_band
      in: query
      required: false
      description: |
        Filter by score band. Comma-separated for multi-value
        (`?score_band=high,critical`). The numeric range the band
        maps to is **matrix-dependent** — on a 5×5 register
        (`iso31000_5x5`) `high` means score ≥ 12 < 20; on a 3×3
        register (`simple_3x3`) `high` means score ≥ 6 < 9. The
        same band name is honoured against whichever matrix the
        target register uses.
      style: form
      explode: false
      schema:
        type: array
        items:
          type: string
          enum: [low, medium, high, critical]
        example: [high, critical]
    RiskFilterScoreType:
      name: score_type
      in: query
      required: false
      description: |
        Selects which score column `score_band` filters against.
        Default `residual` — operators care most about residual
        risk for triage.
      schema:
        type: string
        enum: [inherent, residual]
        default: residual
    RiskFilterOwnerId:
      name: owner_id
      in: query
      required: false
      description: Filter by owner UUID. Single value.
      schema:
        type: string
        format: uuid
    RiskFilterReviewStatus:
      name: review_status
      in: query
      required: false
      description: |
        Filter by review-window state, comma-separated for
        multi-value:
        - `due` — `review_date` is within the next 7 days
          (today through today + 7 days, inclusive).
        - `overdue` — `review_date` is strictly before today.
        - `none` — `review_date IS NULL`.
        "Today" is the current calendar date in **UTC** — the
        server is the source of truth, so clients with skewed
        local clocks see consistent results, and the boundary is
        the same for every tenant regardless of their local
        timezone. The daily review-reminder emails use the same
        UTC anchor, so the filter and the reminders always agree
        about which risks are due or overdue.
      style: form
      explode: false
      schema:
        type: array
        items:
          type: string
          enum: [overdue, due, none]
        example: [due, overdue]
    RiskFilterSearch:
      name: search
      in: query
      required: false
      description: |
        Free-text substring search over `title` and `description`
        (case-insensitive `ILIKE '%term%'`). Term is 1–200 chars,
        control characters stripped. Wildcards (`%`, `_`) and
        the escape character (`\`) are escaped — the term is
        always treated as a literal substring. No index — small
        scale only; a future slice may add a trigram GIN index
        if customer demand surfaces.
      schema:
        type: string
        minLength: 1
        maxLength: 200
    RiskSortColumn:
      name: sort
      in: query
      required: false
      description: |
        Column to sort the returned risks by. Default `createdAt`
        descending (newest first). Every variant carries a stable
        `id DESC` tiebreak so rows with identical primary keys never
        re-order across reloads — mirrors the cursor pagination
        invariant from v0.27.0.0.

        **`sort` cannot be combined with `cursor`** — the cursor
        format encodes `(createdAt, id)` per the v0.27.0.0 contract,
        so mixing a different sort with the cursor predicate would
        produce incoherent pagination. Returns 400 when both are
        present. Fetch the first page sorted (no `cursor`), or
        paginate with the default order (no `sort`).
      schema:
        type: string
        enum: [riskRef, title, inherentScore, residualScore, status, reviewDate, createdAt]
    RiskSortDirection:
      name: dir
      in: query
      required: false
      description: |
        Sort direction. Defaults to `desc`. Ignored when `sort` is
        absent; supplying `dir` alone is a no-op (not a 400).
      schema:
        type: string
        enum: [asc, desc]
        default: desc
    ControlIdPath:
      name: controlId
      in: path
      required: true
      description: |
        Control UUID or stable reference (`CTRL-0042`). Both forms
        resolve to the same row; the resolver normalises case and
        returns 404 for unknown values.
      schema:
        type: string
        pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
    ScopeIdPath:
      name: scopeId
      in: path
      required: true
      description: |
        Scope UUID or stable reference (`SCOPE-0042`). Both forms
        resolve to the same row; the resolver normalises case and
        returns 404 for unknown values.
      schema:
        type: string
        pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
    ImplementationIdPath:
      name: implId
      in: path
      required: true
      description: |
        Control implementation UUID or stable reference (`IMPL-0042`).
        Both forms resolve to the same row; the resolver normalises
        case and returns 404 for unknown values.
      schema:
        type: string
        pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
    EndpointIdPath:
      name: endpointId
      in: path
      required: true
      description: Webhook endpoint UUID.
      schema:
        type: string
        format: uuid
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Optional client-supplied idempotency token. When set, a retry
        with the same key + same body short-circuits to the original
        result (Stripe-style contract). Same key + different body
        returns 409 with `details.subcode = API_V1_IDEMPOTENCY_BODY_MISMATCH`.

        Allowed charset: `[A-Za-z0-9_-]`. Length 1–200 chars.

        Idempotency keys are scoped per-organisation. Two tenants
        reusing the same string each get their own entity. Recommended
        shape: a UUID v4 or a UUID v7.

        **On `DELETE`:** the key is accepted (shape-validated) but a
        `DELETE` does NOT use the stored body-replay path above — it is
        idempotent at the resource level by the operation's own semantics,
        so the same-key retry contract is NOT the "replayed 2xx" you get on
        `POST`/`PATCH`. Retry behaviour depends on the endpoint: an
        entity delete (risk / control / scope / implementation)
        resolves its target first, so a retry after the entity is already
        gone returns **`404`** (registers are archived, never deleted —
        `DELETE /api/v1/registers/{id}` is `405` regardless of retries); a naturally-idempotent delete — unlinking a
        control mapping that is already absent
        (`DELETE /api/v1/risks/{riskId}/controls/{controlId}`) — returns
        **`204`** even on the repeat. In both cases the end state is "gone",
        so treat the retry's response (`404` or `204`) as success
        confirmation, not a failure. The key does not currently guard
        against reuse with a different `DELETE` target.
      schema:
        type: string
        pattern: '^[A-Za-z0-9_-]{1,200}$'
        example: 5a8c1d8a-2c4f-4f3d-9a4f-b6f8c1d9e2a3
    IfMatch:
      name: x-rr-if-match
      in: header
      required: false
      description: |
        **Optimistic concurrency control.** Carry the `ETag` value
        returned by a prior `GET` of this resource in the
        **`x-rr-if-match`** request header. The mutation proceeds only
        if the entity's current state matches; if the entity has been
        modified since you read it, the server returns **412
        Precondition Failed** with `details.subcode = "ETAG_MISMATCH"`
        and the current etag in the response body
        (`details.currentETag`).

        **Use `x-rr-if-match`, not the standard `If-Match`.** This API
        is served behind an edge that enforces HTTP `If-Match`
        preconditions itself: on a *successful* write it can rewrite the
        2xx into a spurious 412 (the post-write entity tag never equals
        the token you sent), so a standard `If-Match` is unreliable on
        the public surface. The server reads the token from
        `x-rr-if-match` first and tolerates a standard `If-Match` only
        as a fallback for non-edge callers — prefer `x-rr-if-match`.

        Mutation responses do **not** carry an `ETag` header (only
        `GET` does) — after a successful write, re-`GET` the resource
        to obtain its new token for the next `x-rr-if-match`.

        Opt-in: omitting the header preserves the existing
        last-writer-wins behaviour.

        Accepts either weak (`W/"…"`, the form the server emits) or
        strong (`"…"`) validators. `x-rr-if-match: *` is **not
        supported** — the use case is per-resource concurrency, never
        "any version will do".
      schema:
        type: string
        example: 'W/"0000019e38ffd8e1"'

  responses:
    BadRequest:
      description: Malformed request (invalid cursor, invalid filter, etc.).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: |
        Missing, malformed, unknown, or revoked Bearer token. The
        response body never differentiates between failure modes.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: |
        Per-credential tier gate failure. Three triggers, each with
        a distinct customer-facing remedy:

        - **`api_access`** — for `rrk_live_*` org keys on a non-
          Business plan. Error body: `"Programmatic API access
          requires a Business plan or above."`. Remedy: upgrade
          the org's plan.
        - **`personal_access_tokens`** — for `rr_pat_live_*` PATs
          when an operator has disabled PATs for the org via
          `org_feature_overrides`. Error body: `"Personal access
          tokens are disabled for this organisation. Contact your
          administrator."`. Remedy: contact your org admin (NOT
          plan upgrade — this is an explicit operator action).
        - **`scopes`** — for any token on the scopes /
          implementations endpoints when the org's plan doesn't
          unlock the second-tier scopes feature.

        Per-credential mutation gate failures additionally carry a
        kind-aware subcode in `details.subcode`:
        `API_KEY_CREATOR_PERMISSION_REVOKED` (org-key minter was
        demoted post-mint), or `PAT_OWNER_PERMISSION_REVOKED`
        (PAT owner was demoted post-mint). Both mean "your token
        authenticates but the acting user lacks the permission this
        mutation requires" — distinct from a generic 403.

        `INSUFFICIENT_SCOPE`: the token's access level doesn't allow this
        request (`details.required` and `details.granted`: `read`,
        `propose` or `write`). A `read` token can only read; a `propose`
        token can also save a draft, start one from a template, make a
        proposal and generate a register (as a draft); every other change
        needs `write`. Nothing was changed.
        The token's owner can make the change in the app, or give the
        token more access on its row under Personal access tokens (an
        app's, under Connected apps). An assistant's app keeps the tools
        it was given when it connected, so it then needs reconnecting, or
        a new conversation, to see the tools the new level allows.

        A plan gate says why in `details.subcode`, so a caller can tell
        a plan's limit from a permission refusal and give the right next
        step:
        - `PLAN_LIMIT`: the plan's limit is reached (`details.limit`:
          `registers`, `users`, `ai_calls_per_month` or `templates`;
          `details.cap`). Upgrading lifts it.
        - `OPERATOR_LIMIT`: the same limit, set by an operator on a plan
          that would lift it. Upgrading doesn't; the message says to
          contact support.
        - `FEATURE_NOT_ENABLED`: the plan lacks the feature
          (`details.feature`); the message names the plan that has it.
        - `FEATURE_TURNED_OFF`: the plan has the feature and an operator
          turned it off (`details.feature`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: |
        The referenced resource doesn't exist or isn't visible to the
        caller's tenant. The two cases are not differentiated.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    TooManyRequests:
      description: |
        Rate limit exceeded. `Retry-After` header carries the integer
        seconds to wait. Two distinct limits apply (see API description).
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            minimum: 1
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InternalError:
      description: |
        Unexpected internal failure. Reported to our error-tracking
        with the `requestId` from the response body — quote it when
        opening a support ticket. Customers should retry with
        exponential backoff; persistent 500s are a service-side
        incident.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NoContent:
      description: |
        Successful operation with no body. Returned on
        `DELETE` of an entity (hard-delete) and on idempotent unlink
        of a risk-control mapping.
    MethodNotAllowed:
      description: |
        Method is not supported on this resource. The `Allow` response
        header lists supported methods. `DELETE /api/v1/registers/{id}`
        returns this — registers are archived (POST /archive), never
        hard-deleted via this surface.
      headers:
        Allow:
          description: Comma-separated list of supported methods.
          schema:
            type: string
            example: "GET, PATCH"
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: |
        Request conflicts with current state. Sub-classified via
        `details.subcode`:

        - `API_V1_IDEMPOTENCY_BODY_MISMATCH` — same `Idempotency-Key`
          was used with a different request body. Change the key or
          send the original payload.
        - `API_V1_REGISTER_NOT_ACTIVE` — the register is archived or
          closed, so it is read-only: any change to its risks, their
          reviews, treatment actions, control links and acceptances is
          refused until someone makes the register active again
          (`PATCH /api/v1/registers/{registerId}` with `status: active`).
          `details.registerId`, `details.status`.
        - `API_V1_RISK_CLOSED` — the risk is closed, so it takes no new
          treatment action, review request or acceptance. Reopen it
          first. `details.riskId`.

        Per-endpoint subcodes are documented at the endpoint level
        rather than here — e.g. `/api/v1/registers/from-context`
        documents an `AI_GENERATION_IN_FLIGHT` subcode + `Retry-After`
        header as an inline 409 override (the shared component cannot
        declare endpoint-specific headers).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: |
        Request body exceeded the 64 KiB cap. The wire shape carries
        the actual byte length and the cap in `details.byteLength`
        / `details.maxBytes`.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unprocessable:
      description: |
        The request is well formed, but cannot be applied as it stands. For
        a proposal, `details.rejected` lists each refused row by key with
        its reasons (`(register)` for the register block, `(proposal)` when
        it changes nothing), and nothing is stored.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PreconditionRequired:
      description: |
        The request must say what it was based on. A proposal must carry
        `expectedRevision`, the register revision it was written against.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PreconditionFailed:
      description: |
        Optimistic concurrency check failed (`x-rr-if-match` header
        present but stale, or malformed). Sub-classified via
        `details.subcode`:

        - `ETAG_MISMATCH` — the entity has been modified since
          the etag you supplied. Re-fetch the resource and retry
          with the new `ETag`. The response body carries
          `details.currentETag` (server's view) and
          `details.suppliedETag` (the value you sent) for
          diffing.
        - `IF_MATCH_MALFORMED` — the `x-rr-if-match` header value
          didn't match the expected quoted-etag shape
          (`W/"…"` or `"…"`).
        - `IF_MATCH_WILDCARD_UNSUPPORTED` — `x-rr-if-match: *` is
          rejected; supply a specific etag.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  headers:
    ETag:
      description: |
        Weak validator capturing the entity's last-modified time.
        Round-trip via `x-rr-if-match` on a subsequent mutation for
        optimistic concurrency. Format: `W/"<16-hex>"`. Stable
        across server restarts (derived from `updated_at`
        millisecond precision).
      schema:
        type: string
        pattern: '^W/"[0-9a-f]{16}"$'
        example: 'W/"0000019e38ffd8e1"'

  schemas:
    RequestId:
      type: string
      description: Per-request correlation ID — also returned in the `x-request-id` header.
      example: 0f6b6b6e-9b8b-4a8b-8d6e-2f4d7b6a9c1a

    PaginationMeta:
      type: object
      required: [next_cursor, has_more]
      properties:
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor for the next page, or `null` if this is the last page.
        has_more:
          type: boolean
          description: |
            `true` if more rows exist past this page. Authoritative —
            do not infer from `data.length`.

    Error:
      type: object
      required: [error, code, requestId]
      properties:
        error:
          type: string
          description: Human-readable error message.
        code:
          type: string
          enum:
            - VALIDATION_ERROR
            - UNAUTHORIZED
            - FORBIDDEN
            - NOT_FOUND
            - CONFLICT
            - PRECONDITION_FAILED
            - PRECONDITION_REQUIRED
            - UNPROCESSABLE
            - RATE_LIMITED
            - PAYLOAD_TOO_LARGE
            - METHOD_NOT_ALLOWED
            - EXTERNAL_SERVICE_ERROR
            - SERVICE_UNAVAILABLE
            - INTERNAL_ERROR
          description: |
            Machine-readable error code, stable across releases.
            Adding new codes is non-breaking; renaming or removing
            a code requires bumping to `/api/v2/`.
        details:
          type: object
          additionalProperties: true
          description: Optional structured detail (e.g. Zod issue paths on validation errors).
        requestId: { $ref: "#/components/schemas/RequestId" }

    Register:
      type: object
      # Every field below is ALWAYS emitted on the wire — nullable
      # fields carry an explicit `null`, never absent. SDK generators
      # depend on `required` to emit `field: T | null` rather than
      # `field?: T | null` (which would let an absent key satisfy the
      # type). Keep the two lists in sync with the DTO.
      required:
        - id
        - registerRef
        - name
        - slug
        - description
        - type
        - status
        - state
        - clientKey
        - reviewCadence
        - nextReviewDate
        - scoringMatrixKind
        - appetiteThresholdScore
        - appetiteApproachingMargin
        - createdAt
        - updatedAt
        - createdBy
      properties:
        id: { type: string, format: uuid }
        registerRef:
          type: string
          pattern: '^[A-Z]+-\d+$'
          description: |
            Stable human-readable identifier (e.g. `REG-0042`). Always
            present. Accepted in place of the UUID at every path-param
            position (`/api/v1/registers/{id}` resolves both forms).
        name: { type: string }
        slug:
          type: string
          description: URL-safe slug, unique within the org.
        description: { type: string, nullable: true }
        type:
          type: string
          enum: [enterprise, project, engagement, operational, daily, compliance, custom]
        status:
          type: string
          enum: [active, archived, closed]
        state:
          type: string
          enum: [draft, accepted]
          description: |
            `draft` when an assistant saved the register and nobody has
            adopted it yet; `accepted` otherwise, including every register
            created in the app. Orthogonal to `status`.
        clientKey:
          type: string
          nullable: true
          description: |
            The key a saving client gave this register (`register.key`
            in a register document). `null` for registers made any
            other way.
        reviewCadence:
          type: string
          enum: [daily, weekly, fortnightly, monthly, quarterly, annually, none]
        nextReviewDate: { type: string, format: date, nullable: true }
        scoringMatrixKind:
          type: string
          enum: [iso31000_5x5, simple_3x3]
          description: Matrix kind used for risk scoring on this register. Immutable post-creation.
        appetiteThresholdScore:
          type: integer
          nullable: true
          description: >-
            Risk-appetite line (#621) — the residual score a risk breaches.
            `null` = appetite unconfigured. Derive a risk's appetite state with
            the documented `within`/`approaching`/`breach` rule.
        appetiteApproachingMargin:
          type: integer
          nullable: true
          description: >-
            How far below the threshold counts as "approaching". `null` when
            appetite is unconfigured.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        createdBy: { type: string, format: uuid, description: Internal user identifier — opaque. }

    RegisterPage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/RegisterWithRevision" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    RegisterWithRevision:
      description: |
        A register as the reads return it: the register and its revision,
        read in one snapshot. Write responses and webhook payloads carry
        `Register`, without a revision, because a write's revision is
        final only when it commits; read the register afterwards for it.
      allOf:
        - $ref: "#/components/schemas/Register"
        - type: object
          required: [revision, url]
          properties:
            revision:
              type: integer
              minimum: 1
              description: |
                Goes up once for each transaction that changes the
                register's content: its own fields, its risks, their
                control links and custom field values. Settings (appetite,
                status, draft state) and organisation-level objects
                (controls, categories, field definitions) do not move it.
                A new register starts at 1.
            url:
              type: string
              format: uri
              description: |
                The register's page in the app. For a register in the
                `draft` state it is where a person reviews and adopts it,
                so after adding risks to a draft, give the user this link.

    ProposalCreate:
      type: object
      required: [expectedRevision, document, reason]
      additionalProperties: false
      properties:
        expectedRevision:
          type: integer
          minimum: 1
          description: The register revision you read. Its absence is a 428, not a 400.
        document:
          type: object
          additionalProperties: true
          description: |
            The proposal profile of `riskregister.com/v5`: the document
            schema with every field optional, a missing field unchanged.
            Informational fields a read carries (`register.id`, `ref`,
            `key`, `state`, `revision`, a risk's `ref`, `meta`) are
            accepted and ignored.
        reason:
          type: string
          minLength: 1
          maxLength: 2000
          description: Why, for the person deciding.
        supersedes:
          type: string
          format: uuid
          description: One of your own open proposals in this register that this one replaces.
    ProposalSummary:
      type: object
      description: A proposal as a list shows it, without what it changes. A single read gives that, and the document.
      required: [id, registerId, state, baseRevision, reason, changeHash, warnings, createdBy, createdAt, expiresAt, supersededBy, decidedBy, decidedAt, decisionNote, adoptedRevision]
      properties:
        id: { type: string, format: uuid }
        registerId: { type: string, format: uuid }
        state:
          type: string
          enum: [open, adopted, declined, superseded, expired]
        baseRevision:
          type: integer
          description: The register revision the proposal was written against.
        reason: { type: string }
        changeHash:
          type: string
          description: The canonical hash of what it changes. Adoption resolves again and must match it.
        warnings:
          type: array
          items: { type: string }
        createdBy:
          type: object
          required: [userId, tokenId]
          properties:
            userId: { type: string, format: uuid, nullable: true }
            tokenId: { type: string, format: uuid, nullable: true }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        supersededBy: { type: string, format: uuid, nullable: true }
        decidedBy: { type: string, format: uuid, nullable: true }
        decidedAt: { type: string, format: date-time, nullable: true }
        decisionNote: { type: string, nullable: true }
        adoptedRevision: { type: integer, nullable: true }
    Proposal:
      description: A proposal with what adopting it would change.
      allOf:
        - $ref: "#/components/schemas/ProposalSummary"
        - type: object
          required: [changes]
          properties:
            changes:
              type: object
              additionalProperties: true
              description: |
                What adopting it would change, as resolved when it was made:
                `register` (field before and after), `risks` (each `update`
                with its field changes, custom fields and added control links,
                or `create` with its fields), and what the named `categories`
                and `owners` resolve to (null: created at adoption, or kept as
                text).
            document:
              type: object
              additionalProperties: true
              description: The document as proposed. On a single read only.
            reviewUrl:
              type: string
              format: uri
              description: |
                Where a person adopts or declines it: the register's Proposals
                tab. On the response to making one, and on a single read: hand
                it to the person, since nothing changes until they adopt it
                there.
    ProposalDetail:
      description: A proposal as a single read returns it, with the document as proposed and where a person decides it.
      allOf:
        - $ref: "#/components/schemas/Proposal"
        - type: object
          required: [document, reviewUrl]
    RegisterChanges:
      type: object
      required: [registerId, revision, since, changes, truncated]
      properties:
        registerId: { type: string, format: uuid }
        revision:
          type: integer
          minimum: 1
          description: The register's revision now.
        since:
          type: integer
          minimum: 0
          description: The revision asked about, as given.
        changes:
          type: array
          description: |
            Each register or risk changed after `since`, once, at the
            latest revision that changed it; oldest first. At most 500.
          items:
            type: object
            required: [entity, id, ref, revision, deleted]
            properties:
              entity:
                type: string
                enum: [register, risk]
                description: |
                  `register`: its own fields or custom field values.
                  `risk`: the risk, its control links or its custom field
                  values.
              id: { type: string, format: uuid }
              ref:
                type: string
                nullable: true
                description: Its ref (`RISK-0042`, `REG-0003`); `null` once deleted.
              revision: { type: integer, minimum: 1 }
              deleted:
                type: boolean
                description: A risk deleted since `since`.
        truncated:
          type: boolean
          description: |
            More changed than fit in one response. Read the register's
            document again rather than page through.

    RegisterRevisionSummary:
      type: object
      description: |
        A retained revision of a register: what made it, not what it
        holds.
      required:
        [registerId, revision, source, proposalId, reason, projectionHash, createdBy, createdAt]
      properties:
        registerId: { type: string, format: uuid }
        revision: { type: integer, minimum: 1 }
        source:
          type: string
          enum: [adoption]
          description: What made the snapshot. `adoption`, a person adopting a proposal.
        proposalId:
          type: string
          format: uuid
          nullable: true
          description: The adopted proposal.
        reason:
          type: string
          nullable: true
          description: The adopted proposal's reason.
        projectionHash:
          type: string
          pattern: "^[0-9a-f]{64}$"
          description: |
            The canonical SHA-256 hash of the register's content at this
            revision: equal hashes, equal content.
        createdBy:
          type: object
          required: [userId, tokenId]
          properties:
            userId:
              type: string
              format: uuid
              nullable: true
              description: The person who decided; `null` once deleted.
            tokenId:
              type: string
              format: uuid
              nullable: true
              description: "Always `null`, since a decision is made in the app, not through a token."
        createdAt: { type: string, format: date-time }
    RegisterRevision:
      allOf:
        - $ref: "#/components/schemas/RegisterRevisionSummary"
        - type: object
          required: [appliedChanges]
          properties:
            appliedChanges:
              type: object
              additionalProperties: true
              description: |
                What the adoption applied, as the reviewer saw it:
                `register` (its own fields' before and after, or `null`),
                `risks` (each update's before and after, each creation),
                `categories` and `owners` named (owners kept as text only;
                a member is named by id on the risks), and `applied` (the
                outcome: the change hash adopted, the risks created or
                updated, the categories created, how many controls and
                links were created, and every control key's control).

    Risk:
      type: object
      required:
        - id
        - riskRef
        - registerId
        - title
        - description
        - categoryId
        - categoryName
        - categoryColour
        - riskOwnerId
        - unresolvedOwner
        - inherentLikelihood
        - inherentImpact
        - inherentScore
        - residualLikelihood
        - residualImpact
        - residualScore
        - treatment
        - treatmentDecidedAt
        - status
        - reviewDate
        - lastReviewedAt
        - createdAt
        - updatedAt
        - createdBy
      properties:
        id: { type: string, format: uuid }
        riskRef:
          type: string
          pattern: '^[A-Z]+-\d+$'
          description: |
            Stable human-readable identifier (e.g. `RISK-0042`). Always
            present. Accepted in place of the UUID at every path-param
            position (`/api/v1/risks/{id}` resolves both forms). The
            ref is the canonical handle for MCP-driven tools and the
            human-readable audit / activity feed.
        registerId: { type: string, format: uuid }
        title: { type: string }
        description: { type: string, nullable: true }
        categoryId: { type: string, format: uuid }
        categoryName:
          type: string
          description: Live-joined from the linked category at fetch time. Renaming or recolouring the category propagates to subsequent risk reads.
        categoryColour:
          type: string
          description: Live-joined from the linked category at fetch time.
        riskOwnerId: { type: string, format: uuid, nullable: true, description: Internal user identifier — opaque. }
        unresolvedOwner:
          type: string
          nullable: true
          maxLength: 320
          description: |
            An owner named by an import who matched no member of the
            organisation, or a member who isn't on the register, exactly as
            given (a name such as `Sam`, or an email address). `null` otherwise. Never set together with
            `riskOwnerId`, and not ownership: a risk with only this is
            still unowned for every readiness count. Assigning a member
            with `riskOwnerId` clears it; so does a PATCH of
            `unresolvedOwner: null`. No invitation is ever sent for it.
        inherentLikelihood:
          type: integer
          minimum: 1
          description: |
            Raw likelihood score on the parent register's scoring matrix.
            Range is `1..N` where N depends on the register's
            `scoringMatrixKind` — 5 for `iso31000_5x5`, 3 for
            `simple_3x3`. Fetch the parent register to know N.
        inherentImpact:
          type: integer
          minimum: 1
          description: Raw impact score on the parent register's scoring matrix. Same matrix-dependent range as `inherentLikelihood`.
        inherentScore:
          type: integer
          nullable: true
          description: Generated as `inherentLikelihood × inherentImpact`.
        residualLikelihood:
          type: integer
          minimum: 1
          description: Same matrix-dependent range as `inherentLikelihood`.
        residualImpact:
          type: integer
          minimum: 1
          description: Same matrix-dependent range as `inherentLikelihood`.
        residualScore:
          type: integer
          nullable: true
          description: Generated as `residualLikelihood × residualImpact`.
        treatment:
          type: string
          enum: [mitigate, accept, transfer, avoid]
          description: |
            Every new risk starts at `mitigate`, so this alone doesn't say
            anyone chose it: read it with `treatmentDecidedAt`.
        treatmentDecidedAt:
          type: string
          format: date-time
          nullable: true
          description: |
            When a person recorded the treatment decision (treating the
            risk, or formally accepting it). Null when nobody has: the
            treatment is then only the default, "Not yet decided" as the
            app shows it.
        status:
          type: string
          enum: [open, under_review, closed, accepted]
        reviewDate: { type: string, format: date, nullable: true }
        lastReviewedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        createdBy: { type: string, format: uuid, description: Internal user identifier — opaque. }

    RiskPage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Risk" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    RiskHitRegister:
      type: object
      description: |
        The register a search result is in: enough to cite the risk,
        and to propose a change against the register's current
        revision without another read.
      required: [id, registerRef, name, state, status, revision]
      properties:
        id: { type: string, format: uuid }
        registerRef:
          type: string
          pattern: '^[A-Z]+-\d+$'
        name: { type: string }
        state:
          type: string
          enum: [draft, accepted]
          description: "`draft` until someone adopts the register: a draft's risks are unreviewed."
        status:
          type: string
          enum: [active, archived, closed]
        revision:
          type: integer
          minimum: 1
          description: The register's current revision, which a proposal names as `expectedRevision`.

    RiskSearchHit:
      allOf:
        - $ref: "#/components/schemas/Risk"
        - type: object
          required: [register]
          properties:
            register: { $ref: "#/components/schemas/RiskHitRegister" }

    RiskSearchPage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/RiskSearchHit" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    RiskCoverageControl:
      type: object
      required:
        - controlId
        - controlRef
        - title
        - effectivenessStatus
        - effectivenessTracked
        - controlImplementationId
      properties:
        controlId:
          type: string
          description: The control's UUID.
        controlRef:
          type: string
          description: The control's stable reference (`CTRL-0042`).
        title: { type: string }
        effectivenessStatus:
          type: string
          enum: [not_implemented, partially_implemented, effectively_implemented]
          description: |
            The effectiveness that fed coverage classification for this mapping —
            the pinned implementation's own status, or the control-wide aggregate.
        effectivenessTracked:
          type: boolean
          description: |
            Whether `effectivenessStatus` reflects real implementation data. When
            `false`, `not_implemented` means "not assessed" (no effectiveness
            signal — e.g. implementations are a Business+ feature), NOT a control
            evaluated ineffective.
        controlImplementationId:
          type: string
          nullable: true
          description: Non-null ⇒ evaluated at a specific implementation's granularity.

    RiskCoverageGap:
      type: object
      required:
        - riskId
        - riskRef
        - title
        - residualScore
        - residualLikelihood
        - residualImpact
        - status
        - coverageStatus
        - controls
      properties:
        riskId:
          type: string
          description: The risk's UUID.
        riskRef:
          type: string
          description: The risk's stable reference (`RISK-0042`).
        title: { type: string }
        residualScore: { type: integer }
        residualLikelihood: { type: integer }
        residualImpact: { type: integer }
        status:
          type: string
          enum: [open, under_review, accepted]
          description: |
            The risk's status. `closed` never appears — coverage excludes closed
            risks (they need no live control coverage).
        coverageStatus:
          type: string
          enum: [uncovered, under_covered, covered]
          description: |
            `uncovered` = no controls linked; `under_covered` = ≥1 control linked
            but none assessed effective; `covered` = ≥1 control assessed effective
            (covered risks are absent from the gap list).
        controls:
          type: array
          items: { $ref: "#/components/schemas/RiskCoverageControl" }

    RegisterCoverage:
      type: object
      required: [totalRisks, counts, gaps]
      properties:
        totalRisks:
          type: integer
          description: Count of open (non-closed) risks in the register.
        counts:
          type: object
          required: [uncovered, underCovered, covered]
          properties:
            uncovered: { type: integer }
            underCovered: { type: integer }
            covered: { type: integer }
        gaps:
          type: array
          description: The uncovered + under_covered risks, highest residual score first.
          items: { $ref: "#/components/schemas/RiskCoverageGap" }

    Control:
      type: object
      required:
        - id
        - controlRef
        - title
        - description
        - type
        - category
        - controlOwnerId
        - createdAt
        - updatedAt
        - createdBy
      properties:
        id: { type: string, format: uuid }
        controlRef:
          type: string
          pattern: '^[A-Z]+-\d+$'
          description: |
            Stable human-readable identifier (e.g. `CTRL-0042`). Always
            present. Accepted in place of the UUID at every path-param
            position.
        title: { type: string }
        description: { type: string, nullable: true }
        type:
          type: string
          enum: [preventive, detective, corrective, directive]
        category: { type: string, nullable: true, description: Free-text grouping label. }
        controlOwnerId: { type: string, format: uuid, nullable: true, description: Internal user identifier — opaque. }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        createdBy: { type: string, format: uuid, description: Internal user identifier — opaque. }

    ControlPage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Control" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    ControlImplementation:
      type: object
      required:
        - id
        - implementationRef
        - controlId
        - scopeId
        - status
        - effectivenessNotes
        - evidenceUrl
        - evidenceDescription
        - lastReviewedAt
        - reviewDate
        - reviewedBy
        - createdAt
        - updatedAt
        - createdBy
      properties:
        id: { type: string, format: uuid }
        implementationRef:
          type: string
          pattern: '^[A-Z]+-\d+$'
          description: |
            Stable human-readable identifier (e.g. `IMPL-0042`).
            Always present. Accepted at path-param position.
        controlId: { type: string, format: uuid }
        scopeId: { type: string, format: uuid }
        status:
          type: string
          enum: [not_implemented, partially_implemented, effectively_implemented]
        effectivenessNotes: { type: string, nullable: true }
        evidenceUrl: { type: string, nullable: true }
        evidenceDescription: { type: string, nullable: true }
        lastReviewedAt: { type: string, format: date-time, nullable: true }
        reviewDate: { type: string, format: date, nullable: true }
        reviewedBy: { type: string, format: uuid, nullable: true, description: Internal user identifier — opaque. }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        createdBy: { type: string, format: uuid, description: Internal user identifier — opaque. }

    ControlImplementationPage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/ControlImplementation" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    Scope:
      type: object
      required:
        - id
        - scopeRef
        - name
        - description
        - type
        - parentScopeId
        - ownerId
        - status
        - metadata
        - createdAt
        - updatedAt
        - createdBy
      properties:
        id: { type: string, format: uuid }
        scopeRef:
          type: string
          pattern: '^[A-Z]+-\d+$'
          description: |
            Stable human-readable identifier (e.g. `SCOPE-0042`).
            Always present. Accepted at path-param position.
        name: { type: string }
        description: { type: string, nullable: true }
        type:
          type: string
          enum: [site, system, process, department, supplier, data_asset, personnel_group, custom]
        parentScopeId: { type: string, format: uuid, nullable: true, description: Self-referential parent for hierarchy. }
        ownerId: { type: string, format: uuid, nullable: true, description: Internal user identifier — opaque. }
        status:
          type: string
          enum: [active, decommissioned, planned]
        metadata:
          type: object
          additionalProperties: true
          description: Free-form scope-type-specific attributes. Treat unknown fields as opaque.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        createdBy: { type: string, format: uuid, description: Internal user identifier — opaque. }

    ScopePage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    RiskCategory:
      type: object
      required: [id, name, description, colour, sortOrder, isOrgPrivate]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        colour:
          type: string
          description: "Hex colour, e.g. `#a020f0`."
        sortOrder: { type: integer }
        isOrgPrivate:
          type: boolean
          description: |
            `true` if this is an org-defined category, `false` if a
            global system default (seeded). Use to distinguish in UI.

    AuditEntry:
      type: object
      required: [id, timestamp, userId, entityType, entityId, action, changes]
      properties:
        id: { type: string, format: uuid }
        timestamp: { type: string, format: date-time }
        userId:
          type: string
          format: uuid
          nullable: true
          description: |
            Resolved user identifier. `null` for system-actor entries
            (cron, webhook, inngest). The richer actor shape lives on
            `changes.actor` for every entry written from v0.26.0.0
            onwards.
        entityType: { type: string, description: One of the audit_entity_type enum values. }
        entityId: { type: string, format: uuid }
        action: { type: string, description: One of the audit_action enum values. }
        changes:
          type: object
          additionalProperties: true
          description: |
            JSONB diff of old and new values for the mutation, plus the
            actor record on `changes.actor` (slice A1+). The shape is
            action-dependent — consumers should treat it as a JSON
            blob and read fields by name.

    AuditEntryPage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/AuditEntry" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    WebhookEventType:
      type: string
      description: |
        Outbound webhook event types emitted by the platform.
        Customers MUST tolerate unknown values — adding new event
        types is non-breaking.

        Lifecycle / mutation events (v0.40.0.0+):
        - `risk.deleted` — emitted before risk deletion; payload
          carries the final-state snapshot so customers can
          reconcile without a follow-up GET that would 404.
        - `risk.de_escalated` — mirror of `risk.escalated` for
          downward band transitions. Separate event so subscribers
          can opt into upward-only (often the only direction
          pager-worthy) without filtering payloads.
        - `risk.appetite_breached` — fires on the transition
          (non-breach → breach) when a residual re-score pushes a
          risk above its register's configured appetite line.
          Transition-only; co-emitted with the escalation stream.
        - `control.deleted` — mirror of `risk.deleted` for the
          org-wide control surface.
        - `register.created` — fires on every register creation
          surface (cookie-auth POST, public v1 POST, clone, AI
          generation, framework instantiation).
        - `register.archived` — fires on the actual transition to
          `archived` only, whether through `POST .../archive` or a
          `PATCH /api/v1/registers/{registerId}` (or the app's Edit page) that
          sets `status: archived`; idempotent on re-archive (no event
          on a no-op). Un-archiving sends no event.

        Verb-endpoint events (PR-4/5 of the MCP-first pivot):
        - `risk.assessed` — `POST /api/v1/risks/:id/assess`. Carries
          the operator's rationale as a first-class field plus
          before/after L/I scores. Does NOT also emit `risk.updated` —
          subscribers wanting "any change" enable both event types.
          DOES co-emit `risk.escalated` / `risk.de_escalated` when
          the assessment moves the residual band.
        - `risk.treated` — `POST /api/v1/risks/:id/treat`. Mirror of
          `risk.assessed` for the (treatment, status) decision
          surface.

        Review workflow events (PR-6 of the MCP-first pivot, v0.45.0.0):
        - `review.requested` — `POST /api/v1/risks/:id/request-review`.
          Carries the full risk + the freshly-created review row.
          No `previous` field (creation event).
        - `review.completed` — `POST /api/v1/risks/:id/complete-review`.
          Carries `previous: Review` alongside the after-state review
          DTO so subscribers see the lifecycle moment without
          re-fetching. Also fires when someone records a review in the
          app with no request first ("Mark reviewed"); `previous` is
          then `null`.
        - `review.cancelled` — `POST /api/v1/risks/:id/cancel-review`.
          Carries `previous: Review` + the operator's `reason` as a
          first-class field. `review.overdue` (above) is unchanged
          and continues to fire from the review-reminder cron.

        Org-membership events (Slice A1 of the B2B-SaaS-essentials
        sweep, v0.49.28.0):
        - `invitation.created` — fires when an org admin mints (or
          refreshes) an email invitation. Payload carries the
          `Invitation` DTO; deliberately omits credential material
          (`tokenHash`) but includes `tokenPrefix` for ops correlation.
        - `invitation.accepted` — fires when the recipient completes
          the accept orchestration. Carries the same DTO with
          `acceptedAt` + `acceptedByUserId` populated. `invitation.revoked`
          is deliberately NOT exposed — customers care about membership
          changes; a revoked-never-accepted invite is a non-event.

        Organisation events (Slice C1 of the B2B-SaaS-essentials
        sweep, v0.49.29.0):
        - `organisation.updated` — fires when an org admin edits the
          organisation's display `name`, `sector`, `sizeBand`, or
          `region` via the customer-facing /{orgSlug}/settings page.
          Payload carries `Organisation` + `previous: Organisation`.
          Operator-only org mutations (Stripe plan transitions,
          admin suspend / feature-flag overrides) DO NOT fire this
          event — they have their own audit verbs and intentionally
          no webhook surface (operator actions are not customer-
          integration material). The DTO deliberately omits Stripe
          IDs and suspension state.

        User events (A2 of B2B-essentials, v0.49.50.0):
        - `user.updated` — fires when a user edits their own
          profile via the /account/profile page. Today the only
          editable field is the display `name`; future expansions
          (avatar URL, locale) will reuse this event. Payload
          carries `UserProfile` + `previous: UserProfile`.
          Operator role-changes against OTHER users (admin → member,
          remove member) do NOT fire this event — those have their
          own audit verbs with deliberately no webhook surface
          pending a coordination call on what integration
          subscribers should see for membership lifecycle. The DTO
          deliberately omits `auth0Id`, `isSuperadmin`, and
          `orgRole` per the security rationale on the `UserProfile`
          schema.
      enum:
        - risk.created
        - risk.updated
        - risk.deleted
        - risk.escalated
        - risk.de_escalated
        - risk.appetite_breached
        - risk.assessed
        - risk.treated
        - control.status_changed
        - control.deleted
        - register.created
        - register.archived
        - review.overdue
        - review.requested
        - review.completed
        - review.cancelled
        - invitation.created
        - invitation.accepted
        - organisation.updated
        - user.updated
        - treatment_action.created
        - treatment_action.updated
        - treatment_action.status_changed
        - treatment_action.deleted

    Organisation:
      description: |
        Customer-visible organisation projection. Shipped on every
        `organisation.updated` webhook envelope (as both `data.organisation`
        and `data.previous`).

        **Deliberate omissions** (do not file as missing — these are
        operator-only / billing-internal / identity-internal fields the
        public webhook contract intentionally never carries):
        `stripeCustomerId`, `stripeSubscriptionId`, `planPeriodEnd`,
        `suspendedAt`, `suspendedReason`. If you need billing or
        suspension state, read the platform admin surface.
      type: object
      required:
        - id
        - name
        - slug
        - sector
        - sizeBand
        - region
        - plan
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: Display name, 1–120 chars, control chars stripped server-side.
        slug:
          type: string
          description: URL-safe slug. Immutable in this version — slug rename is deferred.
        sector:
          type: string
          nullable: true
          description: >-
            Industry sector (pgEnum-narrowed). `null` when not specified —
            since the streamlined sign-up (migration 0072) the org profile is
            no longer collected at creation; an admin sets it later from the
            settings page. Always present in the response (may be `null`).
        sizeBand:
          type: string
          nullable: true
          description: >-
            Employee headcount band (pgEnum-narrowed). `null` when not
            specified — see `sector`.
        region:
          type: string
          nullable: true
          description: >-
            Operating region (pgEnum-narrowed). `null` when not specified —
            see `sector`.
        plan:
          type: string
          enum: [free, team, business, enterprise]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
          description: Doubles as the `ETag` source on the (future) v1 organisation GET endpoint.

    UserProfile:
      description: |
        Customer-visible user-profile projection. Shipped on every
        `user.updated` webhook envelope (as both `data.user` and
        `data.previous`).

        **Deliberate omissions** (do not file as missing — these are
        identity-internal / operator-only fields the public webhook
        contract intentionally never carries):
        - `auth0Id`: identity subject claim. Surfacing it would
          leak the Auth0 subject and enable targeted Auth0-attack
          vectors against a known account.
        - `isSuperadmin`: platform-operator state. Surfacing it on
          a customer-readable surface invites privilege-probing.
        - `orgRole`: role belongs in the membership context, not
          the user-identity payload. The members API is the
          authoritative read for "what role is X in this org".
      type: object
      required:
        - id
        - email
        - name
        - orgId
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
          description: |
            Auth0-controlled. Read-only on this surface — change
            requires the Auth0 re-verification flow + `users.auth0_id`
            consumer coordination (out of scope as of v0.49.50.0).
        name:
          type: string
          nullable: true
          description: |
            Display name, 1–120 chars, control chars stripped
            server-side. **`null` when the user has not set a display
            name** (the default since v0.49.62.0 / migration 0040 —
            passwordless signup supplies no name claim). The key is
            always present; the value may be null. Clients should fall
            back to `email` for an identifier when `name` is null.
        orgId:
          type: string
          format: uuid
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
          description: Doubles as the `ETag` source on `PATCH /api/account/profile`.

    WebhookEndpoint:
      type: object
      required:
        - id
        - name
        - description
        - url
        - enabledEventTypes
        - status
        - consecutiveFailures
        - createdBy
        - createdAt
        - updatedAt
        - lastSuccessAt
        - disabledAt
        - revokedAt
        - revokedBy
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string, nullable: true }
        url:
          type: string
          format: uri
          description: |
            Customer-supplied destination URL. Validated for `https://`
            scheme + DNS-resolves-to-public-IP at registration AND on
            every delivery (DNS-rebinding TOCTOU defence).
        enabledEventTypes:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
          minItems: 1
          description: |
            Event-type subset this endpoint subscribes to. Setting
            an empty array creates a no-op endpoint and is rejected
            at registration; pause via `status='paused'` instead.
        status:
          type: string
          enum: [active, paused, auto_disabled]
          description: |
            Per-endpoint lifecycle state. `auto_disabled` is set by
            the delivery worker after 20 consecutive failed
            deliveries with no success between them; the operator
            must explicitly re-enable.
        consecutiveFailures:
          type: integer
          minimum: 0
          description: Failed deliveries since the last success. Resets to 0 on any 2xx response.
        createdBy:
          type: string
          format: uuid
          nullable: true
          description: Internal user identifier — opaque. Nullable when the minting user has been removed (audit-log changes.actor preserves provenance).
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        lastSuccessAt: { type: string, format: date-time, nullable: true }
        disabledAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        revokedBy: { type: string, format: uuid, nullable: true }

    WebhookEndpointEnvelope:
      type: object
      required: [data, requestId]
      properties:
        data: { $ref: "#/components/schemas/WebhookEndpoint" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    RetentionConfig:
      type: object
      required: [configured, retentionDays, enabled, lastPurgeAt, lastPurgeDeletedCount]
      properties:
        configured:
          type: boolean
          description: True when an explicit policy exists. False means "retain forever" (the default), and the other fields are null/0.
        retentionDays:
          type: integer
          nullable: true
          description: Days of audit history kept. Null when not configured.
          example: 365
        enabled:
          type: boolean
          description: Master switch. False when not configured or explicitly paused.
        lastPurgeAt:
          type: string
          format: date-time
          nullable: true
          description: When the purge cron last ran for this org, or null.
        lastPurgeDeletedCount:
          type: integer
          description: Rows deleted by the last purge run; 0 when never purged.

    RetentionConfigEnvelope:
      type: object
      required: [data, requestId]
      properties:
        data: { $ref: "#/components/schemas/RetentionConfig" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    SetRetentionConfigRequest:
      type: object
      required: [retentionDays, enabled]
      properties:
        retentionDays:
          type: integer
          minimum: 365
          maximum: 36500
          description: Days of audit history to keep. Floored at the compliance minimum (365).
          example: 730
        enabled:
          type: boolean
          description: Whether the purge cron should act on this policy. Set false to configure-but-pause.

    WebhookEndpointPage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/WebhookEndpoint" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    SiemEndpoint:
      type: object
      required:
        [id, name, url, description, status, consecutiveFailures, lastDeliveredCursorTs, lastDeliveredCursorId, lastSuccessAt, disabledAt, revokedAt, revokedBy, createdBy, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        url: { type: string, description: The SIEM collector URL batches are pushed to. }
        description: { type: string, nullable: true }
        status:
          type: string
          description: Lifecycle status. `auto_disabled` is set by the tailer's circuit breaker.
          enum:
            - active
            - paused
            - auto_disabled
        consecutiveFailures:
          type: integer
          description: Consecutive delivery failures; resets to 0 on success, auto-disables at the threshold.
        lastDeliveredCursorTs:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the last successfully-delivered audit row (the watermark). Null = nothing delivered yet.
        lastDeliveredCursorId:
          type: string
          format: uuid
          nullable: true
          description: Id tiebreak for the delivery watermark.
        lastSuccessAt: { type: string, format: date-time, nullable: true }
        disabledAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        revokedBy: { type: string, format: uuid, nullable: true }
        createdBy: { type: string, format: uuid, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    SiemEndpointEnvelope:
      type: object
      required: [data, requestId]
      properties:
        data: { $ref: "#/components/schemas/SiemEndpoint" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    SiemEndpointPage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/SiemEndpoint" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    WebhookDelivery:
      type: object
      required:
        - id
        - endpointId
        - eventId
        - eventType
        - attempt
        - payload
        - signature
        - timestampUnix
        - status
        - httpStatus
        - responseBody
        - error
        - createdAt
        - deliveredAt
      properties:
        id: { type: string, format: uuid }
        endpointId: { type: string, format: uuid }
        eventId:
          type: string
          format: uuid
          description: Stable across retries — every attempt of one event shares the same `eventId`. Customers dedupe on this value.
        eventType: { $ref: "#/components/schemas/WebhookEventType" }
        attempt:
          type: integer
          minimum: 1
          description: 1-indexed attempt number. An endpoint that fails is tried again after roughly 5 seconds, 5 minutes, 30 minutes and 2 hours, up to five attempts in all; then the event is abandoned for that endpoint. Each retry goes to the endpoint's URL and subscriptions as they are at that moment. Delivery is at least once, so a receiver can occasionally get an event twice; dedupe on the event's `id` in the verified body (the `Webhook-Id` header carries the same value, but isn't signed).
        payload:
          type: object
          additionalProperties: true
          description: |
            JSON snapshot of the envelope sent to the customer.
            Stored as JSONB, so key ordering may differ from the
            exact bytes that were signed. **Use the inbound webhook
            request body for cryptographic verification, not this
            field.** This is forensic evidence ("what we sent, in a
            structured format") rather than a reproducible signing
            input. See docs/webhooks/verifying-signatures.md for the
            canonical verification procedure.
        signature:
          type: string
          description: |
            Hex-encoded HMAC-SHA256 over `timestampUnix + '.' + raw_body`.
            For attempts that never reached the customer's endpoint
            (rate-limit skip, SSRF re-validation failure), this is
            the empty string and `httpStatus` is `null`.
        timestampUnix:
          type: integer
          description: Unix epoch seconds at sign time. Sent as the `Webhook-Timestamp` header.
        status:
          type: string
          enum: [success, failed]
          description: |
            Terminal status of the attempt. The DB schema retains a
            third `pending` enum value for future streaming-status
            support, but the slice-B worker only writes terminal
            values — `pending` will not appear on the wire today.
        httpStatus:
          type: integer
          nullable: true
          description: HTTP status code from the customer's endpoint. `null` when the request never received a response (timeout, DNS failure, TLS error).
        responseBody:
          type: string
          nullable: true
          description: Truncated to 4 KiB. Longer bodies have a `… [truncated]` suffix.
        error:
          type: string
          nullable: true
          description: Transport-layer error message when `httpStatus` is `null`.
        createdAt: { type: string, format: date-time }
        deliveredAt:
          type: string
          format: date-time
          nullable: true
          description: When the HTTP response landed (success or failure with a status code). `null` while `status='pending'`.

    WebhookDeliveryPage:
      type: object
      required: [data, pagination, requestId]
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/WebhookDelivery" }
        pagination: { $ref: "#/components/schemas/PaginationMeta" }
        requestId: { $ref: "#/components/schemas/RequestId" }

    # ─── Slice C: mutation request bodies + RiskControlMapping ────────────

    CreateRegisterRequest:
      type: object
      required: [name, type]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        slug:
          type: string
          minLength: 1
          maxLength: 64
          pattern: '^[a-z0-9]([a-z0-9]|-(?=[a-z0-9]))*$'
          description: URL-safe slug, lowercase alphanumeric + single hyphens. Auto-generated from `name` when omitted; when the name has no slugifiable characters (e.g. entirely non-Latin), the generated slug derives from the register's ref instead (`register-reg-0042`), with a short entropy suffix if that slot is already taken.
        description: { type: string, maxLength: 2000 }
        type:
          type: string
          enum: [enterprise, project, engagement, operational, daily, compliance, custom]
        reviewCadence:
          type: string
          enum: [daily, weekly, fortnightly, monthly, quarterly, annually, none]
          default: annually
          description: >-
            Review cadence. Defaults to `annually` when omitted. When a cadence
            is set and `nextReviewDate` is omitted, the server derives
            `nextReviewDate` from the cadence (e.g. annual → today + 1 year);
            `none` leaves it null.
        nextReviewDate:
          type: string
          format: date
          description: >-
            Next review date (`YYYY-MM-DD`). Optional — when omitted, the server
            derives it from `reviewCadence` (null for the `none` cadence). An
            explicit value is stored as-is.
        scoringMatrixKind:
          type: string
          enum: [iso31000_5x5, simple_3x3]
          default: iso31000_5x5
          description: |
            Per-register scoring matrix. `simple_3x3` requires
            Business plan or above (`configurable_scoring_matrices`
            feature gate). Immutable post-creation.
        customFieldValues:
          type: object
          additionalProperties: true
          description: |
            Map of `customFieldId` → raw value for org-defined custom
            fields with `appliesTo='register'`. Tier-gated by the
            `custom_fields` feature flag. Empty / omitted is a no-op.

    UpdateRegisterRequest:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: string, maxLength: 2000, nullable: true }
        type:
          type: string
          enum: [enterprise, project, engagement, operational, daily, compliance, custom]
        status:
          type: string
          enum: [active, archived]
        reviewCadence:
          type: string
          enum: [daily, weekly, fortnightly, monthly, quarterly, annually, none]
        nextReviewDate: { type: string, format: date, nullable: true }
        customFieldValues:
          type: object
          additionalProperties: true

    SetAppetiteRequest:
      type: object
      additionalProperties: false
      required: [thresholdScore, approachingMargin]
      properties:
        thresholdScore:
          type: integer
          minimum: 1
          nullable: true
          description: >-
            The residual-score line a risk breaches. `null` clears the appetite
            (and forces `approachingMargin` to `null`). Must not exceed the
            register matrix's maximum score.
        approachingMargin:
          type: integer
          minimum: 0
          nullable: true
          description: >-
            How far below the threshold counts as "approaching". Must be strictly
            less than `thresholdScore`. When omitted (sent as `null`) with a
            non-null threshold, it defaults to a band-step below the threshold.

    CreateRegisterFromContextRequest:
      type: object
      required: [name, context]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Display name for the new register.
        slug:
          type: string
          minLength: 1
          maxLength: 64
          pattern: '^[a-z0-9]([a-z0-9]|-(?=[a-z0-9]))*$'
          description: URL-safe slug. Auto-generated from `name` when omitted; when the name has no slugifiable characters, the generated slug derives from the register's ref instead (`register-reg-0042`), with a short entropy suffix if that slot is already taken.
        type:
          type: string
          enum: [enterprise, project, engagement, operational, daily, compliance, custom]
          description: Register type. Defaults to `enterprise` when omitted.
        reviewCadence:
          type: string
          enum: [daily, weekly, fortnightly, monthly, quarterly, annually, none]
          default: annually
          description: >-
            Review cadence for the generated register. Defaults to `annually`
            when omitted; the server derives the register's `nextReviewDate`
            from the cadence.
        scoringMatrixKind:
          type: string
          enum: [iso31000_5x5, simple_3x3]
          description: |
            Per-register scoring matrix. `simple_3x3` requires the
            `configurable_scoring_matrices` feature (Business plan or
            above); Free / Team callers receive 403 if they request
            it. Defaults to `iso31000_5x5`. Immutable post-creation.
        context:
          type: string
          minLength: 1
          maxLength: 4096
          description: |
            Free-form business-context paragraph. Sent verbatim to
            Anthropic Claude alongside the org's available risk
            categories (global + org-private). The model emits 3–25
            seeded risks; categories the model picks are constrained
            to the available set via JSON-schema enums + Zod
            re-validation.

    Template:
      type: object
      description: A register template, an authored register a workspace can start from.
      required: [slug, name, version, summary, domain, registerType, reviewCadence, riskCount, controlCount, alignedFrameworks, coverage, review, illustrative]
      properties:
        slug: { type: string }
        name: { type: string }
        version: { type: string }
        summary: { type: string }
        domain:
          type: string
          enum: [information_security, ai_governance, quality, enterprise]
          description: What the register covers.
        registerType:
          type: string
          enum: [enterprise, project, engagement, operational, daily, compliance, custom]
          description: The behaviour the template selects.
        reviewCadence:
          type: string
          enum: [daily, weekly, fortnightly, monthly, quarterly, annually, none]
        riskCount: { type: integer }
        controlCount:
          type: integer
          description: Controls suggested from our own library, adopted when the template is used.
        alignedFrameworks:
          type: array
          items: { type: string }
          description: Framework catalogues the suggested controls align to.
        coverage:
          type: array
          description: |
            For each aligned framework, how many of its requirements the
            template's risks reach through a linked control, of how many. A
            floor, not a proof: one requirement can imply several risks.
          items:
            type: object
            required: [framework, name, unit, reached, total]
            properties:
              framework: { type: string, description: The framework catalogue's slug. }
              name: { type: string }
              unit:
                type: string
                description: What the framework's requirements are called (controls, requirements, criteria, subcategories).
              reached: { type: integer, minimum: 0 }
              total: { type: integer, minimum: 1 }
        review:
          type: object
          required: [status, reviewedBy, reviewedAt]
          properties:
            status: { type: string, enum: [unreviewed, reviewed] }
            reviewedBy: { type: string, nullable: true }
            reviewedAt: { type: string, format: date, nullable: true }
        illustrative:
          type: boolean
          description: Every scenario is an example to adapt, not a finding about you.
    TemplateDocument:
      type: object
      required: [template, document]
      properties:
        template:
          allOf:
            - $ref: "#/components/schemas/Template"
            - type: object
              required: [guide]
              properties:
                guide:
                  type: object
                  required: [scope, assumptions, scoring, exampleReview]
                  properties:
                    scope: { type: string }
                    assumptions: { type: array, items: { type: string } }
                    scoring:
                      type: string
                      description: How to read the 5×5 scale for this kind of register.
                    exampleReview: { type: string, nullable: true }
        document: { $ref: "#/components/schemas/RegisterDocumentV5" }
    RegisterFromTemplateRequest:
      type: object
      required: [template]
      additionalProperties: false
      properties:
        template: { type: string, description: "The template's slug or id." }
        name: { type: string, maxLength: 200, description: "The register's name; the template's by default." }
        key:
          type: string
          maxLength: 100
          pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$'
          description: The draft's key for later document saves; new in this workspace.
    RegisterFromTemplate:
      type: object
      required: [register, reviewUrl, template, risks, replayed]
      properties:
        register: { $ref: "#/components/schemas/Register" }
        reviewUrl:
          type: string
          description: The register page, where a register admin reviews and adopts the draft.
        template:
          type: object
          required: [slug, name, version]
          properties:
            slug: { type: string }
            name: { type: string }
            version: { type: string }
        risks:
          type: array
          description: Each risk made, keyed by the template risk it came from (its key in the draft).
          items:
            type: object
            required: [key, id, ref]
            properties:
              key: { type: string }
              id: { type: string, format: uuid }
              ref: { type: string }
        replayed: { type: boolean }
    ReviewQueueRisk:
      type: object
      required: [id, ref, title, status, reviewDate, residualScore, ownerEmail, unresolvedOwner]
      properties:
        id: { type: string, format: uuid }
        ref: { type: string }
        title: { type: string }
        status:
          type: string
          enum: [open, under_review, closed, accepted]
        reviewDate: { type: string, format: date, nullable: true }
        residualScore: { type: integer, nullable: true }
        ownerEmail: { type: string, nullable: true, description: "The owner's email when the owner is a member." }
        unresolvedOwner: { type: string, nullable: true, description: "The owner's text when no member matched it." }
    Applicability:
      type: object
      required: [register, framework, statementUrl, insightsUrl, implementationsTracked, liveRiskCount, counts, decidedCount, requirements]
      properties:
        register:
          type: object
          required: [id, ref, name, state]
          properties:
            id: { type: string, format: uuid }
            ref: { type: string }
            name: { type: string }
            state:
              type: string
              enum: [draft, accepted]
        framework:
          type: object
          required: [slug, name, version, attributionNotice]
          properties:
            slug: { type: string }
            name: { type: string }
            version: { type: string }
            attributionNotice:
              type: string
              nullable: true
              description: The catalogue's licence notice; it goes with any rendering of the catalogue.
        statementUrl:
          type: string
          format: uri
          description: The Applicability page, where an owner or admin records decisions and anyone downloads the workbook. It names this register only when the register is accepted and active, the registers the page draws evidence from.
        insightsUrl:
          type: string
          format: uri
          description: The register's Insights tab, where the workbook of this statement downloads for this register whatever its state, a draft or archived one included.
        implementationsTracked: { type: boolean, description: "False below Business: every implementation is then null." }
        liveRiskCount: { type: integer }
        counts:
          type: object
          required: [selected, unlinked, noControl]
          properties:
            selected: { type: integer }
            unlinked: { type: integer }
            noControl: { type: integer }
        decidedCount: { type: integer, description: "How many requirements have a recorded decision." }
        requirements:
          type: array
          items:
            type: object
            required: [ref, title, summary, category, status, controls, risksTreated, decision]
            properties:
              ref: { type: string, description: "The Annex A reference code, e.g. A.5.15." }
              title: { type: string }
              summary: { type: string, nullable: true, description: "riskregister.com's own one-sentence summary." }
              category: { type: string, nullable: true }
              status:
                type: string
                enum: [selected, unlinked, no_control]
              controls:
                type: array
                items:
                  type: object
                  required: [ref, title, alignment, implementation, linkedRisks]
                  properties:
                    ref: { type: string }
                    title: { type: string }
                    alignment:
                      type: string
                      enum: [covers, partially_covers, related]
                    implementation:
                      type: string
                      nullable: true
                      enum: [not_implemented, partially_implemented, effectively_implemented]
                    linkedRisks:
                      type: array
                      items: { type: string }
              risksTreated:
                type: array
                items: { type: string }
              decision:
                type: object
                nullable: true
                description: The organisation's recorded decision, which stands over the derivation; null while undecided.
                required: [applicable, justification, implementationOverride, decidedAt]
                properties:
                  applicable: { type: boolean }
                  justification:
                    type: string
                    nullable: true
                    description: Required for an exclusion; at most 4,000 characters.
                  implementationOverride:
                    type: string
                    nullable: true
                    enum: [not_implemented, partially_implemented, effectively_implemented]
                    description: A person's own statement of implementation, over the derived one.
                  decidedAt: { type: string, format: date-time }
    TreatmentPlan:
      type: object
      required: [register, planUrl, asOf, counts, actions, gaps]
      properties:
        register:
          type: object
          required: [id, ref, name, state]
          properties:
            id: { type: string, format: uuid }
            ref: { type: string }
            name: { type: string }
            state:
              type: string
              enum: [draft, accepted]
        planUrl: { type: string, format: uri, description: "The register's Treatment tab." }
        asOf: { type: string, format: date, description: "Today in UTC: an action due before it is overdue." }
        counts:
          type: object
          required: [open, inProgress, overdue, done, cancelled]
          properties:
            open: { type: integer }
            inProgress: { type: integer }
            overdue: { type: integer }
            done: { type: integer }
            cancelled: { type: integer }
        actions:
          type: object
          required: [items, truncated]
          properties:
            items:
              type: array
              items:
                type: object
                required: [id, title, status, dueDate, overdue, ownerName, risk, evidence]
                properties:
                  id: { type: string, format: uuid }
                  title: { type: string }
                  status:
                    type: string
                    enum: [open, in_progress, done, cancelled]
                  dueDate: { type: string, format: date, nullable: true }
                  overdue: { type: boolean }
                  ownerName: { type: string, nullable: true }
                  risk:
                    type: object
                    required: [id, ref, title, residualScore, treatment, treatmentDecidedAt]
                    properties:
                      id: { type: string, format: uuid }
                      ref: { type: string }
                      title: { type: string }
                      residualScore: { type: integer, nullable: true }
                      treatment:
                        type: string
                        enum: [mitigate, accept, transfer, avoid]
                      treatmentDecidedAt:
                        type: string
                        format: date-time
                        nullable: true
                        description: Null when nobody has decided the treatment; it is then only the default.
                  evidence:
                    type: array
                    maxItems: 5
                    description: Links to what shows the work was done, oldest first.
                    items: { $ref: "#/components/schemas/EvidenceLink" }
            truncated: { type: boolean }
        gaps:
          type: array
          description: Live risks decided as mitigate with no open or in-progress action.
          items:
            type: object
            required: [riskId, riskRef, riskTitle, residualScore, treatmentDecidedAt]
            properties:
              riskId: { type: string, format: uuid }
              riskRef: { type: string }
              riskTitle: { type: string }
              residualScore: { type: integer, nullable: true }
              treatmentDecidedAt: { type: string, format: date-time }
    ReviewQueue:
      type: object
      required: [register, reviewUrl, asOf, overdue, dueSoon, reviewsPending, acceptancesLapsed, unresolvedOwners, proposalsOpen]
      properties:
        register:
          type: object
          required: [id, ref, name, state]
          properties:
            id: { type: string, format: uuid }
            ref: { type: string }
            name: { type: string }
            state:
              type: string
              enum: [draft, accepted]
        reviewUrl: { type: string, format: uri, description: "The register page, where people act on the queue." }
        asOf: { type: string, format: date, description: "The UTC date the queue was read against." }
        overdue:
          type: object
          required: [items, truncated]
          properties:
            items: { type: array, items: { $ref: "#/components/schemas/ReviewQueueRisk" } }
            truncated: { type: boolean }
        dueSoon:
          type: object
          required: [items, truncated]
          properties:
            items: { type: array, items: { $ref: "#/components/schemas/ReviewQueueRisk" } }
            truncated: { type: boolean }
        reviewsPending:
          type: object
          required: [items, truncated]
          properties:
            items:
              type: array
              items:
                type: object
                required: [reviewId, riskId, riskRef, riskTitle, dueDate, requestedAt, assigneeEmail]
                properties:
                  reviewId: { type: string, format: uuid }
                  riskId: { type: string, format: uuid }
                  riskRef: { type: string }
                  riskTitle: { type: string }
                  dueDate: { type: string, format: date-time, nullable: true }
                  requestedAt: { type: string, format: date-time }
                  assigneeEmail: { type: string, nullable: true }
            truncated: { type: boolean }
        acceptancesLapsed:
          type: object
          required: [items, truncated]
          properties:
            items:
              type: array
              items:
                type: object
                required: [acceptanceId, riskId, riskRef, riskTitle, expiresAt]
                properties:
                  acceptanceId: { type: string, format: uuid }
                  riskId: { type: string, format: uuid }
                  riskRef: { type: string }
                  riskTitle: { type: string }
                  expiresAt: { type: string, format: date-time }
            truncated: { type: boolean }
        unresolvedOwners:
          type: object
          required: [items, truncated]
          properties:
            items: { type: array, items: { $ref: "#/components/schemas/ReviewQueueRisk" } }
            truncated: { type: boolean }
        proposalsOpen:
          type: object
          description: Agents' proposals waiting for a decision, the soonest to lapse first. One past its expiry is left out.
          required: [items, truncated]
          properties:
            items:
              type: array
              items:
                type: object
                required: [proposalId, reason, baseRevision, createdAt, expiresAt, createdBy]
                properties:
                  proposalId: { type: string, format: uuid }
                  reason: { type: string }
                  baseRevision: { type: integer }
                  createdAt: { type: string, format: date-time }
                  expiresAt: { type: string, format: date-time }
                  createdBy:
                    type: object
                    required: [userId, tokenId]
                    properties:
                      userId: { type: string, format: uuid, nullable: true }
                      tokenId: { type: string, format: uuid, nullable: true }
            truncated: { type: boolean }
    Capacity:
      type: object
      required: [used, cap, remaining, source]
      properties:
        used: { type: integer }
        cap: { type: integer, nullable: true, description: "Null when there is no cap." }
        remaining: { type: integer, nullable: true, description: "Null when there is no cap." }
        source:
          type: string
          nullable: true
          enum: [plan, operator]
          description: "Who sets the cap, as a save refused at it will say. `plan`: the plan's limit (`PLAN_LIMIT`); upgrading lifts it. `operator`: the plan has no cap here, but an operator setting does (`OPERATOR_LIMIT`); contact support, not an upgrade. Null when there is no cap."
    MyWorkRegister:
      type: object
      required: [id, ref, name]
      properties:
        id: { type: string, format: uuid }
        ref: { type: string }
        name: { type: string }
    MyWork:
      type: object
      required: [myWorkUrl, asOf, reviewsAssigned, acceptancesLapsed, actionsOwned, risksOwned, elsewhere]
      properties:
        myWorkUrl: { type: string, format: uri, description: "The person's My Work page." }
        asOf: { type: string, format: date, description: "Today in UTC, the date `overdue` is read against." }
        reviewsAssigned:
          type: object
          required: [items, truncated]
          properties:
            items:
              type: array
              items:
                type: object
                required: [reviewId, riskId, riskRef, riskTitle, register, dueDate, overdue, url]
                properties:
                  reviewId: { type: string, format: uuid }
                  riskId: { type: string, format: uuid }
                  riskRef: { type: string }
                  riskTitle: { type: string }
                  register: { $ref: "#/components/schemas/MyWorkRegister" }
                  dueDate: { type: string, format: date-time, nullable: true }
                  overdue: { type: boolean }
                  url: { type: string, format: uri, description: "The risk's page, where the review is done." }
            truncated: { type: boolean }
        acceptancesLapsed:
          type: object
          required: [items, truncated]
          properties:
            items:
              type: array
              items:
                type: object
                required: [acceptanceId, riskId, riskRef, riskTitle, register, expiresAt, url]
                properties:
                  acceptanceId: { type: string, format: uuid }
                  riskId: { type: string, format: uuid }
                  riskRef: { type: string }
                  riskTitle: { type: string }
                  register: { $ref: "#/components/schemas/MyWorkRegister" }
                  expiresAt: { type: string, format: date-time }
                  url: { type: string, format: uri }
            truncated: { type: boolean }
        actionsOwned:
          type: object
          required: [items, truncated]
          properties:
            items:
              type: array
              items:
                type: object
                required: [actionId, title, status, dueDate, overdue, riskId, riskRef, riskTitle, register, url]
                properties:
                  actionId: { type: string, format: uuid }
                  title: { type: string }
                  status:
                    type: string
                    enum: [open, in_progress]
                  dueDate: { type: string, format: date, nullable: true }
                  overdue: { type: boolean }
                  riskId: { type: string, format: uuid }
                  riskRef: { type: string }
                  riskTitle: { type: string }
                  register: { $ref: "#/components/schemas/MyWorkRegister" }
                  url: { type: string, format: uri, description: "The risk's treatment plan." }
            truncated: { type: boolean }
        risksOwned:
          type: object
          required: [items, truncated]
          properties:
            items:
              type: array
              items:
                type: object
                required: [id, ref, title, status, residualScore, reviewDate, reviewOverdue, register, url]
                properties:
                  id: { type: string, format: uuid }
                  ref: { type: string }
                  title: { type: string }
                  status: { type: string }
                  residualScore: { type: integer }
                  reviewDate: { type: string, format: date, nullable: true }
                  reviewOverdue: { type: boolean }
                  register: { $ref: "#/components/schemas/MyWorkRegister" }
                  url: { type: string, format: uri }
            truncated: { type: boolean }
        elsewhere:
          type: object
          description: Work in registers the person can't read, counted and not listed.
          required: [count, atLeast]
          properties:
            count: { type: integer }
            atLeast: { type: boolean, description: "A list there was cut short, so `count` is a floor." }
    Member:
      type: object
      description: An active member of the workspace.
      required: [id, name, email, orgRole]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        email: { type: string, format: email }
        orgRole:
          type: string
          enum: [owner, admin, member]
    WorkspaceContext:
      type: object
      required: [principal, workspace, features, limits, links]
      properties:
        principal:
          type: object
          required: [kind, token, user]
          properties:
            kind:
              type: string
              enum: [api_key, personal_access_token, oauth]
              description: '`oauth`: an app connected through OAuth, which only an MCP tool call reaches; never seen at this endpoint, which refuses an OAuth token.'
            token:
              type: object
              description: The token, named by its id, name and prefix; never its secret. `access` is what it may do (an organisation key is `write`). `expiresAt` is when it stops working. For `oauth`, the connection, named by its id, the app's name, and the app's OAuth client id as `prefix`.
              required: [id, name, prefix, access, expiresAt]
              properties:
                id: { type: string, format: uuid }
                name: { type: string }
                prefix: { type: string }
                expiresAt:
                  type: string
                  format: date-time
                  nullable: true
                  description: When the token stops working; null never (an organisation key doesn't expire, nor an OAuth connection).
                access:
                  type: string
                  enum: [read, propose, write]
                  description: '`read`: reads only. `propose`: reads, and the saves that wait for a person (a draft save, a template start, a proposal, a generated register as a draft). `write`: everything the token''s user may do.'
            user:
              type: object
              description: The user the token acts as (an organisation key acts as its creator).
              required: [id, email, name, orgRole]
              properties:
                id: { type: string, format: uuid }
                email: { type: string }
                name: { type: string, nullable: true }
                orgRole:
                  type: string
                  enum: [owner, admin, member]
        workspace:
          type: object
          required: [id, name, slug, plan, trial]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            slug: { type: string }
            plan:
              type: string
              enum: [free, team, business, enterprise]
              description: The plan the workspace is on. A Team trial keeps it `free`; `trial` says when one runs.
            trial:
              type: object
              nullable: true
              description: >-
                The trial the workspace is on, else null: its plan and when
                it ends. While it runs the workspace has that plan's
                features (`features` already says so).
              required: [plan, endsAt]
              properties:
                plan:
                  type: string
                  enum: [free, team, business, enterprise]
                endsAt: { type: string, format: date-time }
        features:
          type: object
          description: >-
            Every feature key, and whether this workspace has it: the
            plan's matrix, then any operator override.
          additionalProperties: { type: boolean }
        limits:
          type: object
          required: [registers, users, documentSave, rateLimits]
          properties:
            registers:
              allOf:
                - $ref: "#/components/schemas/Capacity"
              description: Drafts count toward the register cap.
            users: { $ref: "#/components/schemas/Capacity" }
            documentSave:
              type: object
              required: [maxRisks, maxBytes]
              properties:
                maxRisks: { type: integer }
                maxBytes: { type: integer }
            rateLimits:
              type: object
              required: [readsPerMinute, mutationsPerMinute]
              properties:
                readsPerMinute: { type: integer }
                mutationsPerMinute: { type: integer }
        links:
          type: object
          required: [documentSchema, apiReference, saveDocument]
          properties:
            documentSchema: { type: string, format: uri }
            apiReference: { type: string, format: uri }
            saveDocument: { type: string, format: uri }
    RegisterDocumentV5:
      type: object
      description: |
        A `riskregister.com/v5` register document. The authoritative
        JSON Schema, with every field, limit and enum, is published at
        `/schemas/register/v5.json` and is generated from the same
        definition the server validates with; this entry names only the
        required top level.
      required: [format, register, risks]
      properties:
        format:
          type: string
          pattern: '^riskregister\\.com/v5$'
        register:
          type: object
          required: [name]
          properties:
            name: { type: string }
            key:
              type: string
              maxLength: 100
              pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$'
            state:
              type: string
              enum: [draft, accepted]
              description: >-
                Whether the register is a draft awaiting adoption.
                Written by reads and exports; a save ignores it and
                always writes a draft.
            revision:
              type: integer
              minimum: 1
              description: >-
                The register's revision when the document was read.
                Written by reads and exports; a save and import ignore it.
          additionalProperties: true
        risks:
          type: array
          items:
            type: object
            required: [key, title]
            additionalProperties: true
      additionalProperties: true
    RegisterDocumentSavedRisk:
      type: object
      required: [key, id, ref, outcome]
      properties:
        key: { type: string, description: "The key the document gave the risk." }
        id: { type: string, format: uuid }
        ref: { type: string, description: "The risk's reference, e.g. `RISK-0042`." }
        outcome:
          type: string
          enum: [created, updated, unchanged]
          description: >-
            `created`: a new risk. `updated`: a risk already in the
            draft under this key, changed by this save. `unchanged`: a
            risk already in the draft, which this save left as it was.
    RegisterDocumentSave:
      type: object
      required: [register, reviewUrl, mode, risks, rejected, warnings, controls, categoriesCreated, replayed]
      properties:
        register: { $ref: "#/components/schemas/Register" }
        reviewUrl:
          type: string
          description: The register page, where a register admin reviews and adopts the draft.
        mode:
          type: string
          enum: [create, merge]
          description: >-
            `create`: a new draft. `merge`: saved into the draft the
            document's `register.key` already names.
        risks:
          type: array
          items: { $ref: "#/components/schemas/RegisterDocumentSavedRisk" }
        rejected:
          type: array
          description: Risks not saved, each with why.
          items:
            type: object
            required: [key, reasons]
            properties:
              key: { type: string }
              reasons: { type: array, items: { type: string } }
        warnings:
          type: array
          description: What the document carries that this save does not store.
          items: { type: string }
        controls:
          type: object
          required: [linked, created, notLinked]
          properties:
            linked: { type: integer, description: "Existing controls the saved risks now link to." }
            created: { type: integer, description: "Controls created from the document." }
            notLinked: { type: integer, description: "Document controls no saved risk names (not created)." }
        categoriesCreated:
          type: array
          items: { type: string }
        replayed:
          type: boolean
          description: True when this replays an earlier request with the same Idempotency-Key.
    RegisterFromContextResponse:
      type: object
      required: [register, reviewUrl, risks, generationId, model, inputTokens, outputTokens, durationMs]
      properties:
        register:
          allOf: [{ $ref: "#/components/schemas/Register" }]
          description: The new register. Its `state` follows the caller's access, `draft` with a `propose` token or app (a person adopts or discards it in the app), `accepted` with `write` or an organisation key.
        reviewUrl:
          type: string
          format: uri
          description: The register's page, where a person adopts or discards a draft, or reads a live register.
        risks:
          type: array
          description: All risks the generation produced, in deterministic insertion order.
          items: { $ref: "#/components/schemas/Risk" }
        generationId:
          type: string
          format: uuid
          description: |
            Per-generation correlation id. Joins to the `usage_events`
            row stamped on quota debit, the `'ai_generated'` audit
            row's `changes.generationId`, and the `usage_events`
            metadata for retries. Stable across idempotency replay.
        model:
          type: string
          description: Anthropic model the generation ran against. Informational.
        inputTokens:
          type: integer
          minimum: 0
          description: Anthropic input-token count. Informational, surfaced for telemetry.
        outputTokens:
          type: integer
          minimum: 0
          description: Anthropic output-token count. Informational.
        durationMs:
          type: integer
          minimum: 0
          description: LLM call wall-clock duration in milliseconds. Informational.

    CreateRiskRequest:
      type: object
      required: [title, categoryId, inherentLikelihood, inherentImpact, residualLikelihood, residualImpact, treatment]
      properties:
        title: { type: string, minLength: 1, maxLength: 500 }
        description: { type: string, maxLength: 5000 }
        categoryId:
          type: string
          format: uuid
          description: Must resolve to a category visible to the caller's org (global or org-private).
        riskOwnerId:
          type: string
          format: uuid
          description: Optional. When set, must be an active member of the caller's org who can read the register (an org owner or admin, or anyone on it); otherwise 400 `VALIDATION_ERROR`, subcode `ASSIGNEE_NOT_ON_REGISTER` for someone not on the register.
        inherentLikelihood:
          type: integer
          minimum: 1
          description: 1–N where N is the parent register's scoring-matrix scale (5 for `iso31000_5x5`, 3 for `simple_3x3`). The runtime enforces the matrix-specific upper bound and returns 400 on overrun; no `maximum` is declared on the request schema so SDK validators don't assume the wider `iso31000_5x5` ceiling.
        inherentImpact: { type: integer, minimum: 1, description: "See inherentLikelihood — bound is matrix-dependent." }
        residualLikelihood: { type: integer, minimum: 1, description: "See inherentLikelihood — bound is matrix-dependent." }
        residualImpact: { type: integer, minimum: 1, description: "See inherentLikelihood — bound is matrix-dependent." }
        treatment:
          type: string
          enum: [mitigate, accept, transfer, avoid]
        status:
          type: string
          enum: [open, under_review, closed, accepted]
          default: open
        reviewDate:
          type: string
          format: date
          nullable: true
          description: |
            The risk's next review. Omitted, a live risk takes the
            register's next review from its `reviewCadence` (today plus
            one cadence step), so it enters the review loop. `null` asks
            for no date, and is kept. A register whose cadence is `none`,
            or a risk created `closed`, gets no date.
        controlIds:
          type: array
          items:
            type: string
            pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
          maxItems: 50
          description: Optional. Each id is a control UUID or stable reference (`CTRL-0042`) — both resolve to the same control. Each must reference a control visible to the caller's org. Duplicates deduped server-side. Inserted as `risk_control_mappings` atomically with the risk.
        customFieldValues:
          type: object
          additionalProperties: true

    UpdateRiskRequest:
      type: object
      properties:
        title: { type: string, minLength: 1, maxLength: 500 }
        description: { type: string, maxLength: 5000, nullable: true }
        categoryId: { type: string, format: uuid }
        riskOwnerId:
          type: string
          format: uuid
          nullable: true
          description: Assigning a member also clears the risk's `unresolvedOwner`. `null` removes the member and leaves any `unresolvedOwner` as it is. A new owner must be able to read the register (400, subcode `ASSIGNEE_NOT_ON_REGISTER`); resending the current owner is always accepted.
        unresolvedOwner:
          type: string
          nullable: true
          description: Send `null` to remove an unresolved owner's text without assigning anyone. Only `null` is accepted; a string is rejected with 400 (the text arrives through imports, never through an edit).
        inherentLikelihood: { type: integer, minimum: 1, description: "Matrix-dependent upper bound — see CreateRiskRequest.inherentLikelihood." }
        inherentImpact: { type: integer, minimum: 1, description: "Matrix-dependent upper bound." }
        residualLikelihood: { type: integer, minimum: 1, description: "Matrix-dependent upper bound." }
        residualImpact: { type: integer, minimum: 1, description: "Matrix-dependent upper bound." }
        treatment:
          type: string
          enum: [mitigate, accept, transfer, avoid]
        status:
          type: string
          enum: [open, under_review, closed, accepted]
        reviewDate: { type: string, format: date, nullable: true }
        customFieldValues:
          type: object
          additionalProperties: true
        shapeChangeJustification:
          type: string
          minLength: 1
          maxLength: 2000
          description: >-
            Required ONLY when a score edit results in the residual score
            exceeding the inherent score (#621) — controls normally reduce risk,
            so this is an unusual-but-legitimate shape (e.g. a transferred risk).
            Omitting it in that case returns 400 with details.subcode
            RESIDUAL_EXCEEDS_INHERENT. Not persisted as a risk field; captured in
            the audit trail.

    AssessRiskRequest:
      type: object
      required: [inherentLikelihood, inherentImpact, residualLikelihood, residualImpact, rationale]
      additionalProperties: false
      properties:
        inherentLikelihood:
          type: integer
          minimum: 1
          description: 1–N where N is the parent register's scoring-matrix scale (5 for `iso31000_5x5`, 3 for `simple_3x3`).
        inherentImpact: { type: integer, minimum: 1, description: "Matrix-dependent upper bound." }
        residualLikelihood: { type: integer, minimum: 1, description: "Matrix-dependent upper bound." }
        residualImpact: { type: integer, minimum: 1, description: "Matrix-dependent upper bound." }
        rationale:
          type: string
          minLength: 10
          maxLength: 5000
          description: |
            Operator's written justification for this assessment. Control characters
            are stripped server-side; the post-strip trimmed length must remain ≥ 10.
            Lands on the `'assessed'` audit row's `changes.rationale` and on the
            `risk.assessed` webhook payload as a first-class field.

    TreatRiskRequest:
      type: object
      required: [status, rationale]
      additionalProperties: false
      properties:
        treatment:
          type: string
          enum: [mitigate, accept, transfer, avoid]
          description: |
            The treatment decided, recorded with who decided it and when
            (`treatmentDecidedAt`). Leave it out to change the status alone
            (closing a risk, say): the treatment and its decision stay as
            they are.
        status:
          type: string
          enum: [open, under_review, closed, accepted]
          description: |
            Target status after the treatment decision. No state-machine
            restrictions — operators sometimes reopen closed risks.
        rationale:
          type: string
          minLength: 10
          maxLength: 5000
          description: |
            Operator's written justification. Stripped of control chars; post-strip
            length must remain ≥ 10. Lands on the `'treated'` audit row's
            `changes.rationale` and on the `risk.treated` webhook payload.

    PublicActor:
      type: object
      description: |
        Sanitised actor envelope for the public REST API. The full
        Actor union (credential prefix / name / id, superadmin email)
        lives on the admin-gated audit-log surface
        (`/api/v1/audit-log` → `changes.actor`); customer-facing
        surfaces — review DTOs, webhook payloads — receive this
        narrowed projection. Branch on `type` for variant-specific
        logic; rely on `userId` for the acting human's identity
        (where applicable).
      required: [type]
      additionalProperties: false
      properties:
        type:
          type: string
          enum: [user, api_key, personal_access_token, oauth_client, impersonated, system, unknown]
          description: |
            Discriminator. `impersonated` means a platform operator
            acted on your organisation under a time-boxed support
            session; `userId` is the tenant user they acted as (the
            operator's own identity is deliberately not exposed). You
            will observe this on webhook/audit surfaces when support
            has touched your tenant. `oauth_client` means an assistant
            app the person connected through OAuth (claude.ai,
            ChatGPT) acted as them. `unknown` is reserved for
            forward-compat — a future Actor variant that lands before
            this enum is extended surfaces as `unknown` rather than
            leaking unexpected fields verbatim.
        userId:
          type: string
          format: uuid
          description: |
            The acting user's id. Present for `user`,
            `personal_access_token`, `oauth_client` (the person who
            connected the app; which app stays on the admin-only
            audit log), and `impersonated` (the
            impersonated tenant user, NOT the operator). Present for
            `api_key` as the minting admin's id. Omitted for
            `system` actors that have no human counterpart.

    RiskReviewStatus:
      type: string
      enum: [pending, completed, cancelled]
      description: |
        Lifecycle state. `pending` is the only initial state;
        `completed` and `cancelled` are terminal. There is no
        reopen — operators create a new review request instead.

    RiskReviewOutcome:
      type: string
      enum: [no_change, escalated, downgraded, closed, accepted]
      description: |
        The reviewer's product-level decision recorded at completion.

    RiskAcceptance:
      type: object
      description: |
        A formal risk-acceptance record (`risk_acceptances` row). Terminal
        state machine: `active → expired | superseded | revoked`.
      required:
        - id
        - riskId
        - acceptedBy
        - rationale
        - residualScoreAtAcceptance
        - appetiteThresholdAtAcceptance
        - aboveAppetite
        - expiresAt
        - status
        - supersededBy
        - revokedReason
        - createdAt
        - updatedAt
      properties:
        id: { type: string, format: uuid }
        riskId: { type: string, format: uuid }
        acceptedBy:
          type: string
          format: uuid
          nullable: true
          description: Approver user id; null if that user was later removed.
        rationale: { type: string }
        residualScoreAtAcceptance:
          type: integer
          description: The risk's residual score at acceptance time (snapshot).
        appetiteThresholdAtAcceptance:
          type: integer
          nullable: true
          description: The register's appetite threshold at acceptance time; null if unconfigured.
        aboveAppetite:
          type: boolean
          description: Whether the acceptance was above the appetite line (required admin/owner).
        expiresAt: { type: string, format: date, nullable: true }
        status:
          type: string
          enum: [active, expired, superseded, revoked]
        supersededBy:
          type: string
          format: uuid
          nullable: true
          description: The acceptance that superseded this one, if any.
        revokedReason: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    AcceptRiskRequest:
      type: object
      additionalProperties: false
      required: [rationale, expiresAt]
      properties:
        rationale: { type: string, minLength: 1, maxLength: 2000 }
        expiresAt:
          type: string
          format: date
          nullable: true
          description: Acceptance expiry (YYYY-MM-DD), or null for no expiry.
        aboveAppetiteAck:
          type: boolean
          description: >-
            Advisory client acknowledgement that the risk is above appetite.
            The authoritative gate is role-based (admin/owner) in the server.

    RevokeAcceptanceRequest:
      type: object
      additionalProperties: false
      required: [reason]
      properties:
        reason: { type: string, minLength: 1, maxLength: 2000 }

    TreatmentAction:
      type: object
      description: |
        One owned, dated item on a risk's treatment plan — a
        `treatment_actions` row (#622).
      required:
        - id
        - riskId
        - title
        - description
        - ownerUserId
        - dueDate
        - status
        - notes
        - createdAt
        - updatedAt
        - evidence
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        riskId: { type: string, format: uuid }
        title: { type: string }
        description: { type: string, nullable: true }
        ownerUserId:
          type: string
          format: uuid
          nullable: true
          description: |
            Internal user identifier — opaque, treat as a string. Becomes
            null when the owner is removed from the organisation: the
            action stays on the plan rather than leaving with them.
        dueDate:
          type: string
          format: date
          nullable: true
          description: |
            A calendar day (`YYYY-MM-DD`), not a timestamp — a plan
            deadline is a day someone committed to. Past dates are legal;
            an overdue action is by definition already past its due date.
        status:
          type: string
          enum: [open, in_progress, done, cancelled]
        notes: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        evidence:
          type: array
          maxItems: 5
          description: |
            Links to what shows the work was done, oldest first. They are
            added and removed one at a time on the risk page; adding or
            removing one sends `treatment_action.updated`.
          items: { $ref: "#/components/schemas/EvidenceLink" }

    CreateTreatmentActionRequest:
      type: object
      required: [title]
      additionalProperties: false
      # Optional fields are OMITTED to leave unset — explicit `null` is
      # rejected by the strict create schema (use PATCH's nullable fields
      # to clear a value on an existing action).
      properties:
        title: { type: string, minLength: 1 }
        description: { type: string }
        ownerUserId:
          type: string
          format: uuid
          description: Must be an active member of the same organisation who can read the risk's register (400, subcode `ASSIGNEE_NOT_ON_REGISTER`).
        dueDate: { type: string, format: date }
        notes: { type: string }

    UpdateTreatmentActionRequest:
      type: object
      description: |
        At least one field is required. An explicit `null` clears a
        nullable field; an omitted field is left unchanged. `status` is
        NOT editable here — use the status endpoint, whose permission
        rule depends on the transition.
      minProperties: 1
      additionalProperties: false
      properties:
        title: { type: string, minLength: 1 }
        description: { type: string, nullable: true }
        ownerUserId:
          type: string
          format: uuid
          nullable: true
          description: A new owner must be an active member who can read the risk's register (400, subcode `ASSIGNEE_NOT_ON_REGISTER`); resending the current owner is always accepted.
        dueDate: { type: string, format: date, nullable: true }
        notes: { type: string, nullable: true }

    SetTreatmentActionStatusRequest:
      type: object
      required: [status]
      additionalProperties: false
      properties:
        status:
          type: string
          enum: [open, in_progress, done, cancelled]
        reason:
          type: string
          nullable: true
          description: Required when cancelling; optional otherwise.
        expectedCurrentStatus:
          type: string
          enum: [open, in_progress, done, cancelled]
          description: |
            Optional freshness claim. If it disagrees with the stored
            status the call fails `409` with subcode
            `TREATMENT_ACTION_STATE_CHANGED` before any work happens.

    Review:
      type: object
      description: |
        A `risk_reviews` row. Spans both PENDING request shape and
        COMPLETED / CANCELLED record shape — every completion-only
        field is nullable for pending rows; every cancel-only field
        is nullable for non-cancelled rows.
      required:
        - id
        - riskId
        - assignedToUserId
        - assignedByActor
        - dueDate
        - status
        - requestedAt
        - cancelledReason
        - updatedAt
        - reviewedBy
        - outcome
        - notes
        - reviewedAt
        - evidence
      additionalProperties: false
      properties:
        id: { type: string, format: uuid }
        riskId: { type: string, format: uuid }
        assignedToUserId:
          type: string
          format: uuid
          nullable: true
          description: |
            Internal user identifier — opaque, treat as a string.
            Always non-null for `pending` reviews (DB CHECK enforces
            this); can become null on `completed` / `cancelled` rows
            after the assigned user is removed from the org. The
            historical assignee identity is preserved on
            `assignedByActor.userId` for forensic queries.
        assignedByActor:
          $ref: "#/components/schemas/PublicActor"
          description: |
            Sanitised Actor envelope captured at request time.
            Discriminated by `type`; only `type` and `userId` are
            exposed on the public-API surface. The full envelope
            (credential prefix / name, superadmin email when the
            request ran under impersonation) is operator-tier and
            available via the admin-gated `/api/v1/audit-log`
            endpoint's `changes.actor` field. Customers should
            branch on `type` rather than assume a specific variant.
        dueDate:
          type: string
          format: date-time
          nullable: true
          description: Operator-supplied deadline. NULL = no deadline.
        status: { $ref: "#/components/schemas/RiskReviewStatus" }
        requestedAt:
          type: string
          format: date-time
          description: When the review request was opened.
        cancelledReason:
          type: string
          nullable: true
          maxLength: 5000
          description: Operator-supplied reason on cancel. NULL for non-cancelled rows.
        updatedAt:
          type: string
          format: date-time
          description: |
            Last mutation timestamp. Drives the `ETag` header for
            opt-in `x-rr-if-match` optimistic concurrency on
            `/complete-review` and `/cancel-review`.
        reviewedBy:
          type: string
          format: uuid
          nullable: true
          description: |
            The user who marked the review complete. NULL for pending
            rows, for cancelled rows, and for completed rows whose
            reviewer has since been removed from the org (the column
            is `ON DELETE SET NULL`).
        outcome:
          type: string
          enum: [no_change, escalated, downgraded, closed, accepted]
          nullable: true
          description: |
            The reviewer's product-level decision recorded at completion.
            NULL for pending and cancelled rows. Mirrors `RiskReviewOutcome`.
        notes:
          type: string
          nullable: true
          maxLength: 5000
          description: Optional notes captured at request time OR completion time.
        reviewedAt:
          type: string
          format: date-time
          nullable: true
          description: When the review was marked complete. NULL until completion.
        evidence:
          type: array
          maxItems: 5
          description: |
            Links to what supports the review (a ticket, a document, a
            test result), oldest first. Added as the review completes, so
            a pending or cancelled review has none.
          items: { $ref: "#/components/schemas/EvidenceLink" }

    EvidenceLink:
      type: object
      required: [url, label]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          maxLength: 2000
          description: An http or https URL.
        label:
          type: string
          nullable: true
          maxLength: 200
          description: What the link is, or null.

    EvidenceLinkInput:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          maxLength: 2000
          description: An http or https URL; any other scheme is refused with 400.
        label:
          type: string
          nullable: true
          maxLength: 200
          description: |
            What the link is ("Pen test report, March"). Control
            characters are stripped; blank is stored as null.

    RequestRiskReviewRequest:
      type: object
      required: [assignedToUserId]
      additionalProperties: false
      properties:
        assignedToUserId:
          type: string
          format: uuid
          description: |
            UUID of an active user in the caller's organisation who can
            read the risk's register (an org owner or admin, or anyone on
            it). Cross-tenant ids are rejected with 400; someone not on the
            register with 400, subcode `ASSIGNEE_NOT_ON_REGISTER`.
        dueDate:
          type: string
          description: |
            Optional ISO-8601 datetime OR date-only (`YYYY-MM-DD`).
            Date-only is normalised to start-of-day UTC server-side.
          example: "2026-06-15"
        notes:
          type: string
          minLength: 1
          maxLength: 5000
          description: |
            Optional context for the reviewer. Stripped of C0 / DEL
            control characters at the boundary.

    RecordRiskReviewRequest:
      type: object
      required: [outcome]
      additionalProperties: false
      properties:
        outcome: { $ref: "#/components/schemas/RiskReviewOutcome" }
        notes:
          type: string
          minLength: 1
          maxLength: 5000
          description: Optional notes. Stripped of control characters.
        nextReviewDate:
          type: string
          format: date
          description: |
            Optional reschedule: a `YYYY-MM-DD` date, today or later. The
            risk's `reviewDate` rolls forward to it. Absent leaves
            `reviewDate` unchanged; `lastReviewedAt` advances either way.
        evidence:
          type: array
          maxItems: 5
          description: Optional links to what supports the review, at most five.
          items: { $ref: "#/components/schemas/EvidenceLinkInput" }
    CompleteRiskReviewRequest:
      type: object
      required: [reviewId, outcome]
      additionalProperties: false
      properties:
        reviewId:
          type: string
          format: uuid
          description: |
            The pending review row to complete. The URL pins the parent
            risk; the body picks WHICH pending review on that risk to
            complete (multiple pending reviews are allowed on one
            risk). Mismatched riskId / reviewId returns 404.
        outcome: { $ref: "#/components/schemas/RiskReviewOutcome" }
        notes:
          type: string
          minLength: 1
          maxLength: 5000
          description: Optional completion notes. Stripped of control characters.
        nextReviewDate:
          type: string
          format: date
          description: |
            Optional reschedule. When supplied (a `YYYY-MM-DD` date that
            must be today or in the future — a past date is rejected 400),
            completing the review also rolls the parent risk's `reviewDate`
            forward to this date, clearing its overdue state in the same
            call. Absent leaves `reviewDate` unchanged; completion always
            advances `lastReviewedAt` regardless. Distinct from the review's
            own `dueDate` (a full timestamp). Requires register `write`
            (same as editing `reviewDate` via the risk PATCH) — the
            assignee/admin complete gate alone is not sufficient when this
            field is present.
        evidence:
          type: array
          maxItems: 5
          description: Optional links to what supports the review, at most five.
          items: { $ref: "#/components/schemas/EvidenceLinkInput" }

    CancelRiskReviewRequest:
      type: object
      required: [reviewId, reason]
      additionalProperties: false
      properties:
        reviewId:
          type: string
          format: uuid
          description: The pending review row to cancel. Same URL-bound semantic as `/complete-review`.
        reason:
          type: string
          minLength: 1
          maxLength: 5000
          description: |
            Operator-supplied reason for cancellation. Required —
            cancels need a paper trail. Lands on the `'review_cancelled'`
            audit row's `changes.reason` and on the `review.cancelled`
            webhook payload as a first-class field.

    CreateControlRequest:
      type: object
      required: [title, type]
      properties:
        title: { type: string, minLength: 1, maxLength: 500 }
        description: { type: string, maxLength: 5000 }
        type:
          type: string
          enum: [preventive, detective, corrective, directive]
        category:
          type: string
          maxLength: 200
          description: Free-text grouping (e.g. "Physical", "Technical"). Per-org product language, not an enum.
        controlOwnerId:
          type: string
          format: uuid
          description: Optional. When set, must reference a user in the caller's org.
        customFieldValues:
          type: object
          additionalProperties: true

    UpdateControlRequest:
      type: object
      properties:
        title: { type: string, minLength: 1, maxLength: 500 }
        description: { type: string, maxLength: 5000, nullable: true }
        type:
          type: string
          enum: [preventive, detective, corrective, directive]
        category: { type: string, maxLength: 200, nullable: true }
        controlOwnerId: { type: string, format: uuid, nullable: true }
        customFieldValues:
          type: object
          additionalProperties: true

    CreateScopeRequest:
      type: object
      required: [name, type]
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: string, maxLength: 5000 }
        type:
          type: string
          enum: [site, system, process, department, supplier, data_asset, personnel_group, custom]
        parentScopeId:
          type: string
          pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
          description: Optional. A scope UUID or stable reference (`SCOPE-0042`) — both resolve to the same scope. When set, must reference a scope in the caller's tenant. Self-parent rejected.
        ownerId:
          type: string
          format: uuid
          description: Optional. When set, must reference a user in the caller's org.
        status:
          type: string
          enum: [active, decommissioned, planned]
        metadata:
          type: object
          additionalProperties: true
          description: |
            Free-form JSON metadata bag. Per-tenant convention,
            no fixed schema. Capped at 16 KiB serialised.

    UpdateScopeRequest:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 200 }
        description: { type: string, maxLength: 5000, nullable: true }
        type:
          type: string
          enum: [site, system, process, department, supplier, data_asset, personnel_group, custom]
        parentScopeId:
          type: string
          pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
          nullable: true
          description: |
            Set to a new parent — a scope UUID or stable reference
            (`SCOPE-0042`), both resolving to the same scope — or `null`
            to make this a root scope. Self-parent + multi-step cycles
            (A → B → A, A → B → C → A) are rejected with 400.
        ownerId: { type: string, format: uuid, nullable: true }
        status:
          type: string
          enum: [active, decommissioned, planned]
        metadata:
          type: object
          additionalProperties: true

    CreateControlImplementationRequest:
      type: object
      required: [controlId, scopeId, status]
      properties:
        controlId:
          type: string
          pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
          description: A control UUID or stable reference (`CTRL-0042`) — both resolve to the same control. Must reference a control visible to the caller's tenant. **Write-once** — not updatable via PATCH.
        scopeId:
          type: string
          pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
          description: A scope UUID or stable reference (`SCOPE-0042`) — both resolve to the same scope. Must reference a scope visible to the caller's tenant. **Write-once** — not updatable via PATCH.
        status:
          type: string
          enum: [effectively_implemented, partially_implemented, not_implemented]
        effectivenessNotes: { type: string, maxLength: 5000 }
        evidenceUrl:
          type: string
          format: uri
          maxLength: 2000
          description: |
            Must use http or https scheme. Other schemes
            (javascript:, data:, etc.) are rejected as a stored-XSS
            defence — evidence URLs are linked from the UI.
        evidenceDescription: { type: string, maxLength: 2000 }
        reviewDate:
          type: string
          format: date
          description: Optional. Next-review-by date (YYYY-MM-DD).

    UpdateControlImplementationRequest:
      type: object
      description: |
        `controlId` and `scopeId` are **write-once** and not
        accepted on update. Posting them produces a 400. To change
        the pair, delete and recreate.
      properties:
        status:
          type: string
          enum: [effectively_implemented, partially_implemented, not_implemented]
        effectivenessNotes: { type: string, maxLength: 5000, nullable: true }
        evidenceUrl:
          type: string
          format: uri
          maxLength: 2000
          nullable: true
        evidenceDescription: { type: string, maxLength: 2000, nullable: true }
        reviewDate:
          type: string
          format: date
          nullable: true

    ReviewControlImplementationRequest:
      type: object
      description: |
        Body is optional — an empty body or missing body proceeds
        with `reviewDate` left unset.
      properties:
        reviewDate:
          type: string
          format: date
          description: Optional. Next-review-by date (YYYY-MM-DD).

    CreateMappingRequest:
      type: object
      properties:
        controlImplementationId:
          type: string
          pattern: '^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z]+-\d+)$'
          description: Optional. An implementation UUID or stable reference (`IMPL-0042`) — both resolve to the same implementation. When set, must reference an implementation of the same control. Business+ only.
        notes: { type: string, maxLength: 2000 }

    RiskControlMapping:
      type: object
      required:
        - id
        - riskId
        - controlId
        - controlImplementationId
        - notes
        - createdAt
        - createdBy
      properties:
        id: { type: string, format: uuid }
        riskId: { type: string, format: uuid }
        controlId: { type: string, format: uuid }
        controlImplementationId:
          type: string
          format: uuid
          nullable: true
        notes: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        createdBy: { type: string, format: uuid, description: Internal user identifier — opaque. }
