openapi: 3.1.0
info:
  title: RiskSafe Public API
  version: 1.0.0
  summary: Programmatic access to obligations, controls, risks, treatment plans, incidents, gap analysis, clients, async jobs, and webhooks.
  description: |
    ## Overview

    The RiskSafe Public API lets an organisation's own integrations read and write
    their compliance data (obligations, controls, risk register, treatment plans,
    incidents, gap analysis, AML/CTF client records) and register webhooks for
    async event notifications.

    ## Authentication

    Every request must include your API key as a Bearer token:

    ```
    Authorization: Bearer <api-key>
    ```

    - **Production** keys are prefixed `rs_live_...`
    - **Test/staging** keys are prefixed `rs_test_...`

    Using the wrong key prefix for the target environment (see **Base URL** above)
    is rejected with `401 Unauthorized`. Each key is scoped to a single
    organisation — there is no cross-tenant access, and no separate org/tenant ID
    needs to be sent. (The `X-Org-ID` header is reserved for a future multi-org
    key format; sending it today has no effect, as it's not read by any route.)

    Requests with a missing, malformed, expired, or revoked key all return `401
    Unauthorized`. Contact [info@risksafeai.com](mailto:info@risksafeai.com)
    to have a key issued or rotated.

    **Schema completeness note**: response schemas below document every field the
    public API contract commits to. Some resources are thin wrappers around a
    larger internal Prisma model — `additionalProperties: true` is set on object
    schemas so a response is never rejected by strict validation, but only the
    documented fields are considered part of the stable public contract.
  contact:
    name: RiskSafe API Support
    email: info@risksafeai.com
    url: https://risksafeai.com/support
  license:
    name: Proprietary
    url: https://risksafeai.com/terms

servers:
  - url: https://api.risksafeai.com/v1
    description: Production
  - url: https://api-staging.risksafeai.com/v1
    description: Staging (sandbox)

security:
  - ApiKeyAuth: []
    OrgId: []

tags:
  - name: Obligations
    description: Regulatory obligations — list/read, plus triggering the AI decomposition/control-generation pipeline.
  - name: Controls
    description: Mitigation controls, their coverage mappings, and evidence.
  - name: Risks
    description: Risk register entries (inherent/residual scoring, mitigation).
  - name: Treatment Plans
    description: Remediation plans linked to risks (and, for legacy rows, controls/obligations/incidents).
  - name: Incidents
    description: Security/compliance incident records.
  - name: Gap Analysis
    description: Async gap-analysis runs and the resulting gap assessments.
  - name: Clients
    description: AML/CTF client records and risk-rating history.
  - name: Jobs
    description: Poll the status of an async pipeline run triggered elsewhere in this API.
  - name: Webhooks
    description: Register/list/remove webhook subscriptions for async event notifications.

paths:
  /obligations:
    get:
      operationId: listObligations
      tags: [Obligations]
      summary: List obligations
      description: Cursor-paginated, scoped to the caller's organisation. Requires the `obligations:read` scope.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          description: Filter by obligation status (e.g. `upcoming`, `completed`, `overdue`).
          schema: { type: string }
      responses:
        '200':
          description: Paginated list of obligations.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Obligation' }
              example:
                data:
                  - id: 101
                    title: "Annual AML/CTF Program Review"
                    category: "AML/CTF"
                    status: "upcoming"
                    dueDate: "2026-12-01T00:00:00.000Z"
                    description: "Conduct the annual AML/CTF program effectiveness review."
                    regulatoryBody: "AUSTRAC"
                    riskLevel: "high"
                    organizationId: 42
                    entityId: 7
                    source: "manual"
                    recurrence: "annual"
                    complianceApproach: "CONTROL_BASED"
                    lifecycleStage: "INGESTED"
                    createdAt: "2026-01-05T09:12:00.000Z"
                    updatedAt: "2026-01-05T09:12:00.000Z"
                meta:
                  nextCursor: 101
                  hasMore: true
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /obligations/{id}:
    get:
      operationId: getObligation
      tags: [Obligations]
      summary: Get an obligation by id
      description: |
        Returns the obligation with its requirement clauses, a summary of linked
        controls, and its attestation (if any). Requires `obligations:read`.
        A non-existent id, a non-numeric id, or an id belonging to another
        organisation all return an identical `404` — the API never reveals
        whether a cross-org id exists.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ObligationId'
      responses:
        '200':
          description: The obligation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ObligationDetail' }
              example:
                data:
                  id: 101
                  title: "Annual AML/CTF Program Review"
                  category: "AML/CTF"
                  status: "upcoming"
                  dueDate: "2026-12-01T00:00:00.000Z"
                  description: "Conduct the annual AML/CTF program effectiveness review."
                  details: "..."
                  regulatoryBody: "AUSTRAC"
                  riskLevel: "high"
                  organizationId: 42
                  entityId: 7
                  requirementClauses:
                    - id: "clz_1a2b3c"
                      statement: "The program must be reviewed at least annually."
                      proofType: "PERIODIC"
                      weight: "PRIMARY"
                      generatedByAI: false
                  controls:
                    - id: 501
                      title: "Annual AML program review control"
                      status: "In Progress"
                      effectiveness: "Effective"
                  attestation: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /obligations/{id}/controls:
    get:
      operationId: listObligationControls
      tags: [Controls]
      summary: List an obligation's control mappings
      description: |
        Returns the `ObligationControl` mappings (coverage rating, mapping source,
        AI confidence) for a single obligation, each with a summary of the
        mapped control. Requires `controls:read`. The obligation id is validated
        as org-owned first — a cross-org or missing obligation id returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ObligationId'
      responses:
        '200':
          description: Control mappings for the obligation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ObligationControlMapping' }
              example:
                data:
                  - id: 9001
                    coverageRating: "FULL"
                    mappingNotes: null
                    confirmedByUser: true
                    aiConfidence: 0.92
                    mappingSource: "AI_SUGGESTED"
                    control:
                      id: 501
                      title: "Annual AML program review control"
                      status: "In Progress"
                      effectiveness: "Effective"
                      generatedByAI: true
                      priority: "High"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /obligations/run:
    post:
      operationId: runObligation
      tags: [Obligations]
      summary: Trigger AI decomposition/control-generation for an obligation
      description: |
        Enqueues an async pipeline run for the given obligation (creates a
        `ControlGenerationJob` row and sends it to the decomposer SQS queue).
        Requires `obligations:write`. Poll the returned `jobId` with
        `GET /jobs/{jobId}`.
      security: [{ ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [obligationId]
              properties:
                obligationId:
                  description: Numeric id (string or number accepted) of an obligation owned by the caller's org.
                  oneOf:
                    - type: integer
                    - type: string
              example:
                obligationId: 101
      responses:
        '200':
          description: Job accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/JobTrigger' }
              example:
                data:
                  jobId: "clx1a2b3c4d5e"
                  status: "PENDING"
        '400': { $ref: '#/components/responses/ValidationErrorLegacy' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /controls:
    get:
      operationId: listControls
      tags: [Controls]
      summary: List controls
      description: Cursor-paginated, org-scoped via the parent obligation. Requires `controls:read`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          schema: { type: string }
          description: Filter by control status (e.g. `Not Started`, `In Progress`, `Completed`).
        - name: obligationId
          in: query
          schema: { type: integer, minimum: 1 }
          description: Filter to controls belonging to one obligation. Must be a positive integer or the request is rejected with `400`.
      responses:
        '200':
          description: Paginated list of controls.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Control' }
              example:
                data:
                  - id: 501
                    obligationId: 101
                    title: "Annual AML program review control"
                    description: "Annual review of the AML/CTF program's effectiveness."
                    status: "In Progress"
                    priority: "High"
                    effectiveness: "Effective"
                    generatedByAI: true
                    entityId: 7
                meta: { nextCursor: 501, hasMore: false }
        '400': { $ref: '#/components/responses/ValidationErrorLegacy' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /controls/{id}:
    get:
      operationId: getControl
      tags: [Controls]
      summary: Get a control by id
      description: Includes coverage-mapping summaries. Requires `controls:read`. Cross-org/missing id returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ControlId'
      responses:
        '200':
          description: The control.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ControlDetail' }
              example:
                data:
                  id: 501
                  obligationId: 101
                  title: "Annual AML program review control"
                  description: "Annual review of the AML/CTF program's effectiveness."
                  status: "In Progress"
                  priority: "High"
                  effectiveness: "Effective"
                  generatedByAI: true
                  entityId: 7
                  obligationMappings:
                    - id: 9001
                      obligationId: 101
                      coverageRating: "FULL"
                      confirmedByUser: true
                      aiConfidence: 0.92
                      mappingSource: "AI_SUGGESTED"
                      createdAt: "2026-01-05T09:20:00.000Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /controls/{id}/evidence:
    get:
      operationId: listControlEvidence
      tags: [Controls]
      summary: List a control's evidence
      description: |
        Returns evidence metadata for a control — never S3 keys/URLs or extracted
        pipeline content. Requires `controls:read`. Cross-org/missing control id
        returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ControlId'
      responses:
        '200':
          description: Evidence list for the control.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ControlEvidence' }
              example:
                data:
                  - id: 7001
                    evidenceType: "Policy Document"
                    description: "2026 AML/CTF Program review sign-off"
                    adequacyRating: "Adequate"
                    adequacyNotes: null
                    linkedObligationIds: ["101"]
                    uploadedAt: "2026-01-10T04:00:00.000Z"
                    createdAt: "2026-01-10T04:00:00.000Z"
                    updatedAt: "2026-01-10T04:00:00.000Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /risks:
    get:
      operationId: listRisks
      tags: [Risks]
      summary: List risks
      description: Cursor-paginated, org-scoped. Requires `risks:read`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          schema: { type: string }
          description: Filters on `mitigationStatus` (e.g. `Open`, `In Progress`, `Mitigated`, `Accepted`, `Closed`).
        - name: rating
          in: query
          schema: { type: string }
          description: Filters on `residualSeverity` (the post-mitigation headline rating) — one of `Critical`, `High`, `Medium`, `Low`.
      responses:
        '200':
          description: Paginated list of risks.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Risk' }
              example:
                data:
                  - id: 21
                    riskReference: "RISK-021"
                    title: "Inadequate transaction monitoring coverage"
                    description: "Current monitoring rules do not cover all high-risk transaction types."
                    category: "AML/CTF"
                    inherentSeverity: "High"
                    inherentLikelihood: "Likely"
                    inherentRiskScore: 16
                    residualSeverity: "Medium"
                    residualLikelihood: "Possible"
                    residualRiskScore: 9
                    mitigationPlan: "Expand rule coverage to all high-risk transaction types."
                    mitigationStatus: "In Progress"
                    organizationId: 42
                meta: { nextCursor: 21, hasMore: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      operationId: createRisk
      tags: [Risks]
      summary: Create a risk
      description: Requires `risks:write`.
      security: [{ ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateRiskRequest' }
            example:
              riskReference: "RISK-022"
              title: "Unvetted third-party payment processor"
              description: "New payment processor onboarded without AML due diligence."
              category: "AML/CTF"
              inherentSeverity: "High"
              inherentLikelihood: "Likely"
              inherentRiskScore: 16
              currentControls: "None yet."
              residualSeverity: "High"
              residualLikelihood: "Possible"
              residualRiskScore: 12
              mitigationPlan: "Complete third-party AML due diligence questionnaire."
              mitigationStatus: "Open"
              targetCompletionDate: "2026-09-01T00:00:00.000Z"
              lastReviewDate: "2026-07-01T00:00:00.000Z"
              nextReviewDate: "2027-01-01T00:00:00.000Z"
      responses:
        '201':
          description: Risk created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Risk' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /risks/{id}:
    get:
      operationId: getRisk
      tags: [Risks]
      summary: Get a risk by id
      description: |
        Requires `risks:read`. `treatmentPlans` is always returned as an empty
        array — the `Risk` model has no direct relation to `TreatmentPlan` in
        this API version. Cross-org/missing id returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RiskId'
      responses:
        '200':
          description: The risk.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Risk'
                      - type: object
                        properties:
                          treatmentPlans:
                            type: array
                            items: {}
                            description: Always empty in this API version — see description above.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      operationId: updateRisk
      tags: [Risks]
      summary: Partially update a risk
      description: Requires `risks:write`. Only fields present in the body are changed. Cross-org/missing id returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/RiskId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateRiskRequest' }
            example:
              mitigationStatus: "Mitigated"
              residualSeverity: "Low"
              residualRiskScore: 4
      responses:
        '200':
          description: Updated risk.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Risk' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /treatment-plans:
    get:
      operationId: listTreatmentPlans
      tags: [Treatment Plans]
      summary: List treatment plans
      description: |
        Cursor-paginated. Org-scoped via a relational OR across
        `organizationId` (API-created rows) and the linked control/obligation/
        incident's org (legacy app-created rows). Requires `treatment-plans:read`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          schema: { type: string }
          description: Filter by status (e.g. `Draft`, `Active`).
        - name: riskId
          in: query
          schema: { type: integer }
          description: Filter to plans linked to one risk. The risk must belong to the caller's org — a missing/cross-org/non-numeric riskId returns `404`.
      responses:
        '200':
          description: Paginated list of treatment plans.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/TreatmentPlan' }
              example:
                data:
                  - id: 301
                    riskId: 21
                    organizationId: 42
                    sourceType: "risk"
                    gapDescription: "Third-party AML due diligence questionnaire"
                    gapType: "General"
                    riskLevel: "Medium"
                    status: "Draft"
                    reviewFrequency: "Quarterly"
                meta: { nextCursor: 301, hasMore: false }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      operationId: createTreatmentPlan
      tags: [Treatment Plans]
      summary: Create a treatment plan
      description: |
        Requires `treatment-plans:write`. `riskId` must reference a risk owned
        by the caller's org (`404` otherwise). Ticket-facing field names are
        remapped onto the real model columns: `title` → `gapDescription`,
        `description` → `notes`, `dueDate` → `targetDate`, `ownerId` → `planOwnerId`.
      security: [{ ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateTreatmentPlanRequest' }
            example:
              riskId: 21
              title: "Complete third-party AML due diligence questionnaire"
              description: "Send and collect the AML due diligence questionnaire from the new payment processor."
              dueDate: "2026-09-01T00:00:00.000Z"
              ownerId: 15
      responses:
        '201':
          description: Treatment plan created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TreatmentPlan' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /treatment-plans/{id}:
    patch:
      operationId: updateTreatmentPlan
      tags: [Treatment Plans]
      summary: Partially update a treatment plan
      description: |
        Requires `treatment-plans:write`. Same field remapping as create, plus
        `status`/`riskLevel`/`gapType` are PATCH-only settable fields. Cross-org
        (via the same relational-OR org check) or missing id returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/TreatmentPlanId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateTreatmentPlanRequest' }
            example:
              status: "Active"
      responses:
        '200':
          description: Updated treatment plan.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TreatmentPlan' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /incidents:
    get:
      operationId: listIncidents
      tags: [Incidents]
      summary: List incidents
      description: Cursor-paginated, org-scoped. Requires `incidents:read`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          schema: { type: string }
          description: Filter by status (e.g. `Open`, `In Progress`, `Resolved`, `Closed`).
        - name: severity
          in: query
          schema: { type: string, enum: [Low, Medium, High, Critical] }
      responses:
        '200':
          description: Paginated list of incidents.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Incident' }
              example:
                data:
                  - id: 61
                    incidentReference: "INC-1751234567890-9F3K"
                    title: "Suspicious transaction not escalated within SLA"
                    description: "A flagged transaction sat unescalated for 96 hours."
                    severity: "High"
                    status: "Open"
                    customerImpact: "None"
                    detectedDate: "2026-07-01T02:00:00.000Z"
                    organizationId: 42
                meta: { nextCursor: 61, hasMore: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      operationId: createIncident
      tags: [Incidents]
      summary: Create an incident
      description: |
        Requires `incidents:write`. `incidentReference` is server-generated
        (`INC-<epoch-ms>-<4 random alphanumeric>`, retried on the rare unique
        collision) — it cannot be supplied by the caller.
      security: [{ ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateIncidentRequest' }
            example:
              title: "Suspicious transaction not escalated within SLA"
              description: "A flagged transaction sat unescalated for 96 hours."
              severity: "High"
              occurredAt: "2026-07-01T02:00:00.000Z"
              affectedSystems: "transaction-monitoring"
      responses:
        '201':
          description: Incident created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Incident' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /incidents/{id}:
    patch:
      operationId: updateIncident
      tags: [Incidents]
      summary: Partially update an incident
      description: Requires `incidents:write`. `incidentReference` is immutable and not settable here. Cross-org/missing id returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/IncidentId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateIncidentRequest' }
            example:
              status: "Resolved"
              resolvedDate: "2026-07-05T10:00:00.000Z"
              rootCause: "Alert queue triage backlog."
              preventiveMeasures: "Added a 24h SLA alert."
      responses:
        '200':
          description: Updated incident.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Incident' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /gap-analysis:
    post:
      operationId: createGapAnalysisRun
      tags: [Gap Analysis]
      summary: Trigger an async gap analysis run
      description: |
        Requires `gap-analysis:write`. Enqueues a `GapAnalysisRun` (fan-out
        pipeline) — `organizationId` always comes from the auth context, never
        the request body. If `obligationIds` is a non-empty array, the run is
        scoped to those obligations; otherwise it is an org-wide run.

        **Note**: the returned `jobId` is a `GapAnalysisRun` id. Unlike
        `POST /obligations/run`'s `jobId` (a `ControlGenerationJob` id), this id
        is **not** pollable via `GET /jobs/{jobId}` in this API version — that
        endpoint only resolves `ControlGenerationJob` rows. Poll
        `GET /gap-analyses` for the resulting `GapAssessment` records once the
        run completes.
      security: [{ ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateGapAnalysisRequest' }
            example:
              obligationIds: ["101", "102"]
              scope: "FULL"
      responses:
        '201':
          description: Gap analysis run accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/JobTrigger' }
              example:
                data:
                  jobId: "run_9f8e7d6c5b4a"
                  status: "PENDING"
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /gap-analyses:
    get:
      operationId: listGapAssessments
      tags: [Gap Analysis]
      summary: List gap assessments
      description: |
        Cursor-paginated (string cuid cursor). Requires `gap-analysis:read`.
        Org-scoped through the required `entity.organisationId` relation
        (`GapAssessment` has no direct `organizationId` column).
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - name: cursor
          in: query
          schema: { type: string }
          description: Opaque cuid cursor from a previous page's `meta.nextCursor`.
        - name: status
          in: query
          schema: { type: string, enum: [OPEN, IN_REMEDIATION, CLOSED, ACCEPTED] }
      responses:
        '200':
          description: Paginated list of gap assessments.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/GapAssessment' }
              example:
                data:
                  - id: "gap_1a2b3c4d5e"
                    obligationId: 101
                    gapType: "COVERAGE"
                    severity: "HIGH"
                    description: "No control mapped to clause 12(3)(a)."
                    status: "OPEN"
                    source: "AI_DETECTED"
                    aiConfidence: 0.87
                    entityId: 7
                meta: { nextCursor: "gap_1a2b3c4d5e", hasMore: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /gap-analyses/{id}:
    get:
      operationId: getGapAssessment
      tags: [Gap Analysis]
      summary: Get a gap assessment by id
      description: |
        Includes remediations and the linked obligation/entity. Requires
        `gap-analysis:read`. Cross-org/missing id returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/GapAssessmentId'
      responses:
        '200':
          description: The gap assessment.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/GapAssessmentDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /clients:
    get:
      operationId: listClients
      tags: [Clients]
      summary: List clients
      description: |
        Cursor-paginated, org-scoped. Each row includes a `riskProfile` summary
        (highest-version `ClientRiskProfile` for that client, or `null`).
        Requires `clients:read`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: kind
          in: query
          schema: { type: string }
          description: Filter by client kind (e.g. `INDIVIDUAL`, `COMPANY`, `TRUST`, `SMSF`, `OTHER`).
        - name: riskRating
          in: query
          schema: { type: string, enum: [LOW, MEDIUM, HIGH] }
          description: "A client qualifies if it has ANY risk-profile row at this rating (not necessarily its current/highest-version one)."
      responses:
        '200':
          description: Paginated list of clients.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ClientListItem' }
              example:
                data:
                  - id: 801
                    organizationId: 42
                    reference: "CL-001"
                    name: "Acme Conveyancing Pty Ltd"
                    kind: "COMPANY"
                    status: "ACTIVE"
                    riskProfile: { rating: "MEDIUM", cddLevel: "STANDARD", version: 2 }
                meta: { nextCursor: 801, hasMore: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      operationId: createClient
      tags: [Clients]
      summary: Create a client
      description: |
        Requires `clients:write`. `(organizationId, reference)` is unique — a
        duplicate `reference` for the same org returns `409 conflict`.
      security: [{ ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateClientRequest' }
            example:
              reference: "CL-002"
              name: "Jane Smith"
              kind: "INDIVIDUAL"
              email: "jane@example.com"
              country: "AU"
      responses:
        '201':
          description: Client created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Client' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /clients/{id}:
    get:
      operationId: getClient
      tags: [Clients]
      summary: Get a client by id
      description: |
        Requires `clients:read`. Includes the current risk profile, all risk
        factors, and the latest decision. Screening results are omitted unless
        `?include=screening` is passed (they are not returned by default).
        Cross-org/missing id returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ClientId'
        - name: include
          in: query
          schema: { type: string, enum: [screening] }
          description: Pass `screening` to include `screeningResults` in the response.
      responses:
        '200':
          description: The client.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ClientDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /clients/{id}/risk-profile:
    patch:
      operationId: patchClientRiskProfile
      tags: [Clients]
      summary: Record a new client risk-rating version
      description: |
        Requires `clients:write`. Always creates a **new** `ClientRiskProfile`
        version (`(max existing version) + 1`) inside a transaction — prior
        versions are never updated or deleted. `cddLevel` defaults from
        `rating` when omitted (`HIGH`→`ENHANCED`, `MEDIUM`→`STANDARD`,
        `LOW`→`SIMPLIFIED`). Returns `201` (a new row is always created).
        Cross-org/missing client id returns `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/ClientId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatchClientRiskProfileRequest' }
            example:
              rating: "HIGH"
              nextReviewDate: "2027-07-01T00:00:00.000Z"
      responses:
        '201':
          description: New risk-profile version created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ClientRiskProfile' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /jobs/{jobId}:
    get:
      operationId: getJob
      tags: [Jobs]
      summary: Poll an async job's status
      description: |
        Polls a `ControlGenerationJob` (created by `POST /obligations/run`),
        scoped to the caller's org. No specific scope is required beyond a
        valid authenticated API key — any key may poll any job in its own org.
        A missing jobId, or one belonging to another org, returns `404`
        (never a distinguishable `403`).
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
          description: The `jobId` returned by `POST /obligations/run`.
      responses:
        '200':
          description: Job status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Job' }
              examples:
                pending:
                  value: { data: { jobId: "clx1a2b3c4d5e", status: "PENDING" } }
                complete:
                  value:
                    data:
                      jobId: "clx1a2b3c4d5e"
                      status: "COMPLETE"
                      result: { controlsCreated: 3 }
                failed:
                  value:
                    data:
                      jobId: "clx1a2b3c4d5e"
                      status: "FAILED"
                      error: "Decomposer worker timed out"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List registered webhooks
      description: Cursor-paginated (string cuid cursor). Requires `webhooks:read`. `secret` is never returned by this route.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - name: cursor
          in: query
          schema: { type: string }
          description: Opaque cuid cursor from a previous page's `meta.nextCursor`.
      responses:
        '200':
          description: Paginated list of webhooks.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedResponse'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Webhook' }
              example:
                data:
                  - id: "whk_1a2b3c4d5e"
                    orgId: 42
                    url: "https://example.com/webhooks/risksafe"
                    eventTypes: ["job.completed", "gap_assessment.created"]
                    active: true
                    createdAt: "2026-06-01T00:00:00.000Z"
                    updatedAt: "2026-06-01T00:00:00.000Z"
                meta: { nextCursor: "whk_1a2b3c4d5e", hasMore: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Register a webhook
      description: |
        Requires `webhooks:write`. `url` must be `https://` (a non-HTTPS URL
        returns `422`). Every entry in `events` must be a known catalog event
        type (an unknown value returns `422` listing the invalid entries and
        the valid catalog). The response is the **only** place `secret` is
        ever returned — store it immediately, it cannot be retrieved again.
        Use it to verify the `X-RiskSafe-Signature` header on every delivery
        (see the Developer Guide's Webhooks section).
      security: [{ ApiKeyAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateWebhookRequest' }
            example:
              url: "https://example.com/webhooks/risksafe"
              events: ["job.completed", "gap_assessment.created"]
      responses:
        '201':
          description: Webhook created (includes `secret` once).
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/WebhookCreated' }
              example:
                data:
                  id: "whk_1a2b3c4d5e"
                  orgId: 42
                  url: "https://example.com/webhooks/risksafe"
                  secret: "8f14e45fceea167a5a36dedd4bea2543dfef066a...redacted-in-docs"
                  eventTypes: ["job.completed", "gap_assessment.created"]
                  active: true
                  createdAt: "2026-06-01T00:00:00.000Z"
                  updatedAt: "2026-06-01T00:00:00.000Z"
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /webhooks/{id}:
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Soft-delete (deactivate) a webhook
      description: |
        Requires `webhooks:write`. Sets `active: false` — the row and its
        delivery-attempt history are retained. Cross-org/missing id returns
        `404`.
      security: [{ ApiKeyAuth: [] }]
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '204':
          description: Webhook deactivated. No response body.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: "rs_live_... / rs_test_..."
      description: |
        Pass your API key as `Authorization: Bearer <key>`. Production keys
        are prefixed `rs_live_`, test/staging keys `rs_test_` — the wrong
        prefix for the target environment is rejected with `401`.
    OrgId:
      type: apiKey
      in: header
      name: X-Org-ID
      description: |
        Reserved for future multi-org API keys. CORS-allowed on every
        response today, but **not currently read by any route** — the
        organisation context is always resolved from the API key itself
        (one key = one org). Sending this header has no effect in this API
        version.

  parameters:
    Limit:
      name: limit
      in: query
      description: Page size. Default `20`, clamped to `[1, 100]`. A missing, non-numeric, or non-positive value silently falls back to the default.
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Cursor:
      name: cursor
      in: query
      description: Opaque numeric cursor from a previous page's `meta.nextCursor`. Omit for the first page.
      schema: { type: integer }
    ObligationId:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: Obligation id.
    ControlId:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: Control id.
    RiskId:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: Risk id.
    TreatmentPlanId:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: Treatment plan id.
    IncidentId:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: Incident id.
    ClientId:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: Client id.
    GapAssessmentId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Gap assessment id (cuid).
    WebhookId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Webhook id (cuid).

  responses:
    Unauthorized:
      description: Missing/invalid API key, wrong environment prefix, expired key, or revoked key past its rotation grace period.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: { code: "unauthorized", message: "Invalid API key" } }
    Forbidden:
      description: Valid key, but missing the required scope, or the org is inactive.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: { code: "forbidden", message: "Missing required scope: obligations:write" } }
    NotFound:
      description: The resource does not exist, or belongs to another organisation (identical response either way — no existence leak).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: { code: "not_found", message: "Not found" } }
    Conflict:
      description: A uniqueness constraint was violated (e.g. duplicate client reference).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: { code: "conflict", message: "A client with this reference already exists" } }
    UnprocessableEntity:
      description: The request body parsed successfully but failed a semantic check (e.g. non-HTTPS webhook URL, unknown event type).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: { code: "validation_error", message: "url must be a valid https:// URL" } }
    RateLimited:
      description: Too many requests for this API key within the current 1-minute window.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: { code: "rate_limited", message: "Rate limit exceeded. Retry after 1 minute." } }
    InternalError:
      description: Unexpected server error. Never includes a stack trace.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: { code: "internal_error", message: "Internal server error" } }
    ValidationError:
      description: |
        Request body failed Zod schema validation. Uses the structured
        `ValidationErrorResponse` shape (a top-level string `error` plus a
        `details` array of Zod issues) — **not** the standard `ErrorResponse`
        envelope used by every other error in this API.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationErrorResponse' }
          example:
            error: "Validation failed"
            details:
              - code: "invalid_type"
                expected: "string"
                received: "undefined"
                path: ["title"]
                message: "Required"
    ValidationErrorLegacy:
      description: |
        Request body failed manual validation (routes not yet migrated to Zod,
        e.g. `POST /obligations/run`). Uses the standard `ErrorResponse`
        envelope with code `validation_error`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: { code: "validation_error", message: "obligationId is required and must be numeric" } }

  schemas:
    PaginatedResponse:
      type: object
      description: Generic cursor-pagination envelope. `data`'s item type is overridden per endpoint.
      properties:
        data:
          type: array
          items: {}
        meta:
          type: object
          properties:
            nextCursor:
              description: Opaque cursor for the next page, or `null` if there is no more data.
              oneOf:
                - type: integer
                - type: string
                - type: "null"
            hasMore:
              type: boolean
      required: [data]

    ErrorResponse:
      type: object
      description: Standard error envelope used by every route except Zod-validated request bodies (see `ValidationErrorResponse`).
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - forbidden
                - not_found
                - validation_error
                - conflict
                - not_implemented
                - rate_limited
                - internal_error
            message:
              type: string
          required: [code, message]
      required: [error]

    ValidationErrorResponse:
      type: object
      description: Error shape returned by Zod-validated create/update routes (risks, treatment-plans, incidents, gap-analysis, clients, webhooks) on a `400`/`422`.
      properties:
        error:
          type: string
          example: "Validation failed"
        details:
          type: array
          description: Raw Zod `ZodIssue[]` — one entry per failed field.
          items:
            type: object
            additionalProperties: true
      required: [error, details]

    JobTrigger:
      type: object
      properties:
        jobId: { type: string, description: "cuid — either a ControlGenerationJob id (POST /obligations/run) or a GapAnalysisRun id (POST /gap-analysis)." }
        status: { type: string, enum: [PENDING] }
      required: [jobId, status]

    Job:
      type: object
      properties:
        jobId: { type: string }
        status: { type: string, enum: [PENDING, RUNNING, COMPLETE, FAILED] }
        result:
          description: Present once `status` is `COMPLETE`. Shape is job-dependent (raw `resultJson`).
          type: object
          additionalProperties: true
        error:
          type: string
          description: Present once `status` is `FAILED`.
      required: [jobId, status]

    Obligation:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        title: { type: string }
        category: { type: string }
        status: { type: string, description: "e.g. upcoming, completed, overdue" }
        dueDate: { type: string, format: date-time }
        description: { type: string }
        details: { type: string }
        documentLink: { type: [string, "null"] }
        documentName: { type: [string, "null"] }
        regulatoryBody: { type: string }
        ownerId: { type: [integer, "null"] }
        organizationId: { type: [integer, "null"] }
        entityId: { type: integer }
        source: { type: string, enum: [manual, ai] }
        recurrence: { type: string, description: "one-time, annual, quarterly, monthly, etc." }
        riskLevel: { type: string, enum: [critical, high, medium, low] }
        jurisdiction: { type: [string, "null"] }
        businessUnit: { type: [string, "null"] }
        complianceFramework: { type: [string, "null"] }
        effectiveness: { type: [string, "null"], enum: [effective, ineffective, null] }
        legalSource: { type: [string, "null"] }
        sectionReference: { type: [string, "null"] }
        complianceApproach: { type: string, enum: [CONTROL_BASED, ATTESTATION_BASED, HYBRID] }
        lifecycleStage: { type: string, enum: [INGESTED, ASSESSING, ASSESSED, CONTROLLED, MONITORED] }
        businessUnitId: { type: [integer, "null"] }
        subBusinessUnitId: { type: [integer, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [id, title, category, status, dueDate, description, regulatoryBody, entityId]

    ObligationDetail:
      allOf:
        - $ref: '#/components/schemas/Obligation'
        - type: object
          properties:
            requirementClauses:
              type: array
              items: { $ref: '#/components/schemas/RequirementClause' }
            controls:
              type: array
              items: { $ref: '#/components/schemas/ControlSummary' }
            attestation:
              oneOf:
                - $ref: '#/components/schemas/ObligationAttestation'
                - type: "null"

    RequirementClause:
      type: object
      additionalProperties: true
      properties:
        id: { type: string }
        obligationId: { type: integer }
        statement: { type: string }
        evidenceTypes: { type: array, items: { type: string } }
        proofType: { type: string, enum: [POINT_IN_TIME, PERIODIC, CONTINUOUS] }
        minimumStandard: { type: [string, "null"] }
        weight: { type: string, enum: [PRIMARY, SECONDARY, SUPPORTING] }
        generatedByAI: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ObligationAttestation:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        obligationId: { type: integer }
        attestedBy: { type: [integer, "null"] }
        attestationDate: { type: [string, "null"], format: date-time }
        attestationStatement: { type: string }
        signature: { type: [string, "null"] }
        witnessedBy: { type: [integer, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ControlSummary:
      type: object
      description: Minimal control fields returned inline on an obligation's `controls` relation.
      properties:
        id: { type: integer }
        title: { type: string }
        status: { type: string }
        effectiveness: { type: [string, "null"] }

    Control:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        obligationId: { type: integer }
        title: { type: string }
        description: { type: string }
        status: { type: string, enum: ["Not Started", "In Progress", "Completed"] }
        priority: { type: string, enum: [High, Medium, Low] }
        ownerId: { type: [integer, "null"] }
        dueDate: { type: [string, "null"], format: date-time }
        effectiveness: { type: [string, "null"], description: "Effective, Partially Effective, Ineffective, or null" }
        effectivenessOverride: { type: boolean }
        controlType: { type: [string, "null"], description: "Manual, Automated, Hybrid" }
        controlNature: { type: [string, "null"], description: "Preventative, Detective, Corrective, Both" }
        entityId: { type: integer }
        generatedByAI: { type: boolean }
        businessUnitId: { type: [integer, "null"] }
        subBusinessUnitId: { type: [integer, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [id, obligationId, title, description, status, priority, entityId]

    ControlDetail:
      allOf:
        - $ref: '#/components/schemas/Control'
        - type: object
          properties:
            obligationMappings:
              type: array
              items: { $ref: '#/components/schemas/ObligationMappingSummary' }

    ObligationMappingSummary:
      type: object
      description: Coverage-mapping summary returned inline on a control's `obligationMappings` relation.
      properties:
        id: { type: integer }
        obligationId: { type: integer }
        coverageRating: { type: string, enum: [FULL, PARTIAL, SUPERFICIAL, UNKNOWN] }
        mappingNotes: { type: [string, "null"] }
        confirmedByUser: { type: boolean }
        aiConfidence: { type: [number, "null"], minimum: 0, maximum: 1 }
        mappingSource: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }

    ObligationControlMapping:
      type: object
      description: Response shape of `GET /obligations/{id}/controls` — a mapping row plus the mapped control summary.
      properties:
        id: { type: integer }
        coverageRating: { type: string, enum: [FULL, PARTIAL, SUPERFICIAL, UNKNOWN] }
        mappingNotes: { type: [string, "null"] }
        confirmedByUser: { type: boolean }
        aiConfidence: { type: [number, "null"], minimum: 0, maximum: 1 }
        mappingSource: { type: [string, "null"] }
        control: { $ref: '#/components/schemas/ControlSummaryExtended' }

    ControlSummaryExtended:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        status: { type: string }
        effectiveness: { type: [string, "null"] }
        generatedByAI: { type: boolean }
        priority: { type: string }

    ControlEvidence:
      type: object
      description: "Evidence metadata only — never S3 keys/URLs (`documentLinks`) or `extractedContent`."
      properties:
        id: { type: integer }
        evidenceType: { type: string }
        description: { type: [string, "null"] }
        adequacyRating: { type: [string, "null"] }
        adequacyNotes: { type: [string, "null"] }
        linkedObligationIds: { type: array, items: { type: string } }
        uploadedAt: { type: [string, "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Risk:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        riskReference: { type: string }
        title: { type: string }
        description: { type: string }
        category: { type: string }
        riskTaxonomyId: { type: [integer, "null"] }
        linkedObligationId: { type: [integer, "null"] }
        inherentSeverity: { type: string, enum: [Critical, High, Medium, Low] }
        inherentLikelihood: { type: string, enum: ["Almost Certain", Likely, Possible, Unlikely, Rare] }
        inherentRiskScore: { type: integer }
        currentControls: { type: string }
        residualSeverity: { type: string, enum: [Critical, High, Medium, Low] }
        residualLikelihood: { type: string, enum: ["Almost Certain", Likely, Possible, Unlikely, Rare] }
        residualRiskScore: { type: integer }
        riskOwnerId: { type: [integer, "null"] }
        mitigationPlan: { type: string }
        mitigationStatus: { type: string, enum: [Open, "In Progress", Mitigated, Accepted, Closed] }
        targetCompletionDate: { type: string, format: date-time }
        lastReviewDate: { type: string, format: date-time }
        nextReviewDate: { type: string, format: date-time }
        notes: { type: [string, "null"] }
        organizationId: { type: [integer, "null"] }
        businessUnitId: { type: [integer, "null"] }
        subBusinessUnitId: { type: [integer, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [id, riskReference, title, description, category, inherentSeverity, inherentLikelihood, inherentRiskScore, residualSeverity, residualLikelihood, residualRiskScore, mitigationPlan, mitigationStatus]

    CreateRiskRequest:
      type: object
      properties:
        riskReference: { type: string, minLength: 1 }
        title: { type: string, minLength: 1 }
        description: { type: string, minLength: 1 }
        category: { type: string, minLength: 1 }
        inherentSeverity: { type: string, enum: [Critical, High, Medium, Low] }
        inherentLikelihood: { type: string, enum: ["Almost Certain", Likely, Possible, Unlikely, Rare] }
        inherentRiskScore: { type: integer }
        currentControls: { type: string }
        residualSeverity: { type: string, enum: [Critical, High, Medium, Low] }
        residualLikelihood: { type: string, enum: ["Almost Certain", Likely, Possible, Unlikely, Rare] }
        residualRiskScore: { type: integer }
        mitigationPlan: { type: string }
        mitigationStatus: { type: string, enum: [Open, "In Progress", Mitigated, Accepted, Closed] }
        targetCompletionDate: { type: string, format: date-time }
        lastReviewDate: { type: string, format: date-time }
        nextReviewDate: { type: string, format: date-time }
        riskTaxonomyId: { type: [integer, "null"] }
        linkedObligationId: { type: [integer, "null"] }
        riskOwnerId: { type: [integer, "null"] }
        notes: { type: [string, "null"] }
      required: [riskReference, title, description, category, inherentSeverity, inherentLikelihood, inherentRiskScore, currentControls, residualSeverity, residualLikelihood, residualRiskScore, mitigationPlan, mitigationStatus, targetCompletionDate, lastReviewDate, nextReviewDate]

    UpdateRiskRequest:
      type: object
      description: Every field from CreateRiskRequest, all optional — only keys present in the body are changed.
      properties:
        riskReference: { type: string, minLength: 1 }
        title: { type: string, minLength: 1 }
        description: { type: string, minLength: 1 }
        category: { type: string, minLength: 1 }
        inherentSeverity: { type: string, enum: [Critical, High, Medium, Low] }
        inherentLikelihood: { type: string, enum: ["Almost Certain", Likely, Possible, Unlikely, Rare] }
        inherentRiskScore: { type: integer }
        currentControls: { type: string }
        residualSeverity: { type: string, enum: [Critical, High, Medium, Low] }
        residualLikelihood: { type: string, enum: ["Almost Certain", Likely, Possible, Unlikely, Rare] }
        residualRiskScore: { type: integer }
        mitigationPlan: { type: string }
        mitigationStatus: { type: string, enum: [Open, "In Progress", Mitigated, Accepted, Closed] }
        targetCompletionDate: { type: string, format: date-time }
        lastReviewDate: { type: string, format: date-time }
        nextReviewDate: { type: string, format: date-time }
        riskTaxonomyId: { type: [integer, "null"] }
        linkedObligationId: { type: [integer, "null"] }
        riskOwnerId: { type: [integer, "null"] }
        notes: { type: [string, "null"] }

    TreatmentPlan:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        controlId: { type: [integer, "null"] }
        obligationId: { type: [integer, "null"] }
        incidentId: { type: [integer, "null"] }
        riskId: { type: [integer, "null"] }
        organizationId: { type: [integer, "null"] }
        sourceType: { type: string, enum: [control, obligation, incident, risk] }
        gapDescription: { type: string, description: "Public `title` maps to this column." }
        gapType: { type: string }
        riskLevel: { type: string }
        status: { type: string, enum: [Draft, Active] }
        planOwnerId: { type: [integer, "null"], description: "Public `ownerId` maps to this column." }
        targetDate: { type: [string, "null"], format: date-time, description: "Public `dueDate` maps to this column." }
        reviewFrequency: { type: string }
        notes: { type: [string, "null"], description: "Public `description` maps to this column." }
        businessUnitId: { type: [integer, "null"] }
        subBusinessUnitId: { type: [integer, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [id, gapDescription, gapType, riskLevel, status, reviewFrequency]

    CreateTreatmentPlanRequest:
      type: object
      properties:
        riskId: { type: integer, minimum: 1 }
        title: { type: string, minLength: 1, description: "Written to `gapDescription`." }
        description: { type: string, minLength: 1, description: "Written to `notes`." }
        dueDate: { type: string, format: date-time, description: "Written to `targetDate`." }
        ownerId: { type: [integer, "null"], description: "Written to `planOwnerId`." }
      required: [riskId, title, description]

    UpdateTreatmentPlanRequest:
      type: object
      properties:
        riskId: { type: integer, minimum: 1 }
        title: { type: string, minLength: 1 }
        description: { type: string, minLength: 1 }
        dueDate: { type: string, format: date-time }
        ownerId: { type: [integer, "null"] }
        status: { type: string, minLength: 1 }
        riskLevel: { type: string, minLength: 1 }
        gapType: { type: string, minLength: 1 }

    Incident:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        incidentReference: { type: string, description: "Server-generated, unique, immutable." }
        title: { type: string }
        description: { type: string }
        obligationsBreached: { type: string, description: "JSON-encoded array of obligation ids." }
        affectedSystems: { type: [string, "null"] }
        customerImpact: { type: string, enum: [None, Low, Medium, High, Critical] }
        customerImpactDetails: { type: [string, "null"] }
        remediation: { type: string }
        status: { type: string, enum: [Open, "In Progress", Resolved, Closed] }
        severity: { type: string, enum: [Low, Medium, High, Critical] }
        detectedDate: { type: string, format: date-time }
        resolvedDate: { type: [string, "null"], format: date-time }
        rootCause: { type: [string, "null"] }
        preventiveMeasures: { type: [string, "null"] }
        ownerId: { type: [integer, "null"] }
        reportedById: { type: [integer, "null"] }
        organizationId: { type: [integer, "null"] }
        businessUnitId: { type: [integer, "null"] }
        subBusinessUnitId: { type: [integer, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [id, incidentReference, title, description, status, severity, detectedDate]

    CreateIncidentRequest:
      type: object
      properties:
        title: { type: string, minLength: 1 }
        description: { type: string, minLength: 1 }
        severity: { type: string, enum: [Low, Medium, High, Critical] }
        occurredAt: { type: string, format: date-time, description: "Defaults to now() if omitted. Written to `detectedDate`." }
        affectedSystems: { type: string }
      required: [title, description, severity]

    UpdateIncidentRequest:
      type: object
      properties:
        title: { type: string, minLength: 1 }
        description: { type: string, minLength: 1 }
        severity: { type: string, enum: [Low, Medium, High, Critical] }
        status: { type: string, minLength: 1 }
        customerImpact: { type: string, minLength: 1 }
        customerImpactDetails: { type: [string, "null"] }
        remediation: { type: string }
        rootCause: { type: [string, "null"] }
        preventiveMeasures: { type: [string, "null"] }
        detectedDate: { type: string, format: date-time }
        resolvedDate: { type: [string, "null"], format: date-time }
        affectedSystems: { type: [string, "null"] }

    CreateGapAnalysisRequest:
      type: object
      properties:
        obligationIds:
          type: array
          items: { type: string, description: "Positive-integer string." }
          description: "Non-empty -> explicit id selection. Omitted/empty -> org-wide run."
        scope: { type: string, enum: [FULL, DELTA], description: "Defaults to FULL." }

    GapAssessment:
      type: object
      additionalProperties: true
      properties:
        id: { type: string, description: "cuid" }
        obligationId: { type: integer }
        controlId: { type: [integer, "null"] }
        evidenceId: { type: [integer, "null"] }
        gapType: { type: string, enum: [COVERAGE, EFFECTIVENESS, DESIGN, CURRENCY, EVIDENCE, ATTESTATION] }
        severity: { type: string, enum: [CRITICAL, HIGH, MEDIUM, LOW] }
        description: { type: string }
        recommendation: { type: [string, "null"] }
        status: { type: string, enum: [OPEN, IN_REMEDIATION, CLOSED, ACCEPTED] }
        dueDate: { type: [string, "null"], format: date-time }
        assignedToId: { type: [integer, "null"] }
        detectedAt: { type: string, format: date-time }
        closedAt: { type: [string, "null"], format: date-time }
        source: { type: string, enum: [MANUAL, AI_DETECTED, TEST_RESULT, REG_CHANGE] }
        aiConfidence: { type: [number, "null"], minimum: 0, maximum: 1 }
        requirementsUncovered: { type: array, items: { type: string } }
        requirementsPartial: { type: array, items: { type: string } }
        regulatoryRiskFlag: { type: boolean }
        humanReviewRequired: { type: boolean }
        humanReviewReason: { type: [string, "null"] }
        entityId: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [id, obligationId, gapType, severity, description, status, source, entityId]

    GapAssessmentDetail:
      allOf:
        - $ref: '#/components/schemas/GapAssessment'
        - type: object
          properties:
            remediations:
              type: array
              items: { $ref: '#/components/schemas/GapRemediation' }
            obligation: { $ref: '#/components/schemas/Obligation' }
            entity:
              type: object
              properties:
                id: { type: integer }
                organisationId: { type: integer }
                name: { type: string }
                isPrimary: { type: boolean }
                entityType: { type: [string, "null"] }

    GapRemediation:
      type: object
      properties:
        id: { type: string }
        gapAssessmentId: { type: string }
        actionDescription: { type: string }
        ownerId: { type: [integer, "null"] }
        targetDate: { type: [string, "null"], format: date-time }
        completionDate: { type: [string, "null"], format: date-time }
        status: { type: string, enum: [PENDING, IN_PROGRESS, COMPLETED, CANCELLED] }
        progressNotes: { type: array, items: { type: string } }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Client:
      type: object
      additionalProperties: true
      properties:
        id: { type: integer }
        organizationId: { type: integer }
        reference: { type: string, description: "Org-assigned, e.g. \"CL-001\". Unique per (organizationId, reference)." }
        name: { type: string }
        kind: { type: string, description: "Free-form. Convention: INDIVIDUAL | COMPANY | TRUST | SMSF | OTHER." }
        email: { type: [string, "null"] }
        phone: { type: [string, "null"] }
        website: { type: [string, "null"] }
        country: { type: [string, "null"], description: "ISO 3166-1 alpha-2." }
        service: { type: [string, "null"] }
        status: { type: string, enum: [ACTIVE, INACTIVE, ARCHIVED] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [id, organizationId, reference, name, kind, status]

    ClientListItem:
      allOf:
        - $ref: '#/components/schemas/Client'
        - type: object
          properties:
            riskProfile:
              description: Highest-version risk profile, or null if none exists yet.
              oneOf:
                - type: object
                  properties:
                    rating: { type: string, enum: [LOW, MEDIUM, HIGH] }
                    cddLevel: { type: string, enum: [SIMPLIFIED, STANDARD, ENHANCED] }
                    version: { type: integer }
                - type: "null"

    ClientDetail:
      allOf:
        - $ref: '#/components/schemas/Client'
        - type: object
          properties:
            riskProfile:
              oneOf:
                - $ref: '#/components/schemas/ClientRiskProfile'
                - type: "null"
            riskFactors:
              type: array
              items: { $ref: '#/components/schemas/ClientRiskFactor' }
            latestDecision:
              oneOf:
                - $ref: '#/components/schemas/ClientDecision'
                - type: "null"
            screeningResults:
              description: Only present when `?include=screening` was passed.
              type: array
              items: { $ref: '#/components/schemas/ClientScreeningResult' }

    CreateClientRequest:
      type: object
      properties:
        reference: { type: string, minLength: 1 }
        name: { type: string, minLength: 1 }
        kind: { type: string, minLength: 1 }
        email: { type: [string, "null"], format: email }
        phone: { type: [string, "null"] }
        website: { type: [string, "null"] }
        country: { type: [string, "null"] }
        service: { type: [string, "null"] }
        status: { type: string, enum: [ACTIVE, INACTIVE, ARCHIVED] }
      required: [reference, name, kind]

    ClientRiskProfile:
      type: object
      properties:
        id: { type: integer }
        clientId: { type: integer }
        rating: { type: string, enum: [LOW, MEDIUM, HIGH] }
        cddLevel: { type: string, enum: [SIMPLIFIED, STANDARD, ENHANCED] }
        nextReviewDate: { type: [string, "null"], format: date-time }
        status: { type: string, enum: [DRAFT, ACTIVE, SUPERSEDED] }
        version: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [id, clientId, rating, cddLevel, status, version]

    PatchClientRiskProfileRequest:
      type: object
      properties:
        rating: { type: string, enum: [LOW, MEDIUM, HIGH] }
        cddLevel: { type: string, enum: [SIMPLIFIED, STANDARD, ENHANCED], description: "Derived from rating when omitted." }
        nextReviewDate: { type: [string, "null"], format: date-time }
        notes: { type: string, description: "Accepted but never persisted — ClientRiskProfile has no notes column." }
      required: [rating]

    ClientRiskFactor:
      type: object
      properties:
        id: { type: integer }
        clientId: { type: integer }
        riskFactorId: { type: [integer, "null"] }
        factorCode: { type: [string, "null"] }
        factorName: { type: string }
        severity: { type: string, enum: [High, Medium, Low] }
        source: { type: string, enum: [import, ai, manual] }
        notes: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ClientDecision:
      type: object
      properties:
        id: { type: integer }
        clientId: { type: integer }
        decision: { type: string, enum: [PROCEED, CONDITIONS, ESCALATE, DECLINE] }
        rationale: { type: string }
        conditions: { type: [string, "null"] }
        approverId: { type: [integer, "null"] }
        approvedAt: { type: [string, "null"], format: date-time }
        effectiveFrom: { type: string, format: date-time }
        effectiveTo: { type: [string, "null"], format: date-time, description: "null = current active decision." }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ClientScreeningResult:
      type: object
      properties:
        id: { type: integer }
        clientId: { type: integer }
        type: { type: string, enum: [ADVERSE_MEDIA, SANCTIONS, PEP, WATCHLIST, OTHER] }
        summary: { type: string }
        evidenceUrls: { type: string, description: "JSON-encoded array of URLs." }
        confidence: { type: [number, "null"], minimum: 0, maximum: 1 }
        source: { type: [string, "null"] }
        provider: { type: [string, "null"] }
        status: { type: string, enum: [PENDING, CONFIRMED, DISMISSED] }
        dismissedReason: { type: [string, "null"] }
        reviewedById: { type: [integer, "null"] }
        reviewedAt: { type: [string, "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Webhook:
      type: object
      description: Public webhook shape — `secret` omitted (see `WebhookCreated`).
      properties:
        id: { type: string, description: "cuid" }
        orgId: { type: integer }
        url: { type: string, format: uri }
        eventTypes:
          type: array
          items:
            type: string
            enum: [job.completed, job.failed, obligation.created, obligation.updated, control.created, control.updated, risk.created, risk.updated, gap_assessment.created, incident.created]
        active: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      required: [id, orgId, url, eventTypes, active]

    WebhookCreated:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          properties:
            secret:
              type: string
              description: "64-character hex secret. Returned ONLY on create — store it immediately, it is never returned again."
          required: [secret]

    CreateWebhookRequest:
      type: object
      properties:
        url: { type: string, format: uri, description: "Must be https:// — a non-HTTPS URL returns 422." }
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum: [job.completed, job.failed, obligation.created, obligation.updated, control.created, control.updated, risk.created, risk.updated, gap_assessment.created, incident.created]
      required: [url, events]
