openapi: 3.1.0
info:
  title: DataVisuals Decision Governance Platform API
  version: "1.0.0"
  description: |
    HTTP API for the DataVisuals Decision Governance Platform — one system
    financial institutions use to log, govern, and audit consequential
    decisions. Operational workflow areas (BSA & fraud, regulatory
    compliance, credit & lending, ALM & treasury, vendor risk, model risk,
    and others) and a strategic / board apex tier all share a single
    decision lifecycle, authority model, and audit trail; institutions
    enable the workflows they're entitled to.

    The API exposes the full lifecycle — signal → owner → decision →
    action → outcome — plus the governance surfaces around it: tenant
    configuration, workflow entitlements, semantic-layer overrides,
    attachments, comments, cross-workflow evidence linkage, board-cycle
    dossiers, and the Decision Governance Index (DGI) measurement.

    ## Conventions

    - **Tenant identifier**: `tenant_id` (string slug — e.g. `riverdale`).
    - **Workflow identifier**: `workflow_id` (string slug — e.g. `02-bsa-fraud`).
    - **Authentication**: a session JWT issued at `POST /auth/login` and stored in an HttpOnly cookie. Subsequent requests carry the cookie automatically; programmatic clients must persist it. There is no `Authorization: Bearer` header path in v1.
    - **API base path**: every documented operation is mounted under `/api`. The `servers` block reflects the full base URL; operation paths in this document are written without the `/api` prefix.
    - **Request IDs**: every response carries an `X-Request-Id` header (UUID). Capture it client-side and include it whenever contacting support — it's the fastest way to find the matching server-side log entry.
    - **Branding**: "DataVisuals" is the product; "Decision Governance" is the category.

    ## Quickstart

    A five-minute walkthrough lives at
    [developer.datavisuals.com/quickstart](https://developer.datavisuals.com/quickstart) —
    sign in, find a decision, work it through the lifecycle. Curl is
    canonical; Python (`requests.Session`) and JavaScript (`fetch` with
    `credentials: 'include'`) variants follow.

    ## Common errors

    The platform's most-encountered error responses, with the JSON shape
    each carries and the right next move. Every error response also
    includes a `X-Request-Id` header — capture it when contacting
    support.

    | Status | `error` code | When you see it | Fix |
    | --- | --- | --- | --- |
    | 400 | `validation_failed` | Required field missing, type mismatch, or stale `eula_version` at signup. | Read `details` for the field-by-field explanation; fix the request body. |
    | 401 | `unauthorized` | No session cookie, or the JWT expired (8 hour TTL), or the JWT was revoked at logout. | Call `POST /auth/login` to re-authenticate; resume with the new cookie. |
    | 401 | `invalid_credentials` | Login attempted with a wrong username or password. | Prompt the user to retry; do not lock or alert on individual 401s. |
    | 403 | `forbidden` | The session is valid but the user lacks the required role, workflow entitlement, or workflow authority for this operation. | Check `entitled_workflows` from `GET /auth/me`; check the workflow's `authority_levels` for the level the operation requires. |
    | 403 | `eula_acceptance_required` | The user's accepted EULA version is older than `EULA_CURRENT_VERSION`. The response carries `current_version`, `accepted_version`, and `eula_url`. | Show the EULA from `eula_url` and call `POST /auth/accept-eula`. The original request can then be retried. |
    | 404 | `not_found` | Resource doesn't exist — or exists in a different tenant and is invisible to the caller. | Verify the ID; confirm `tenant_id` matches. The platform never confirms cross-tenant existence. |
    | 409 | `state_conflict` | State-machine transition rejected — claim-on-claimed-decision, finalize-without-rationale, etc. | Read the current decision state via `GET /decisions/{id}` and pick the right next action. |
    | 409 | `email_in_use` | Signup attempted with a registered email. | Direct the user to `POST /auth/login` or `POST /auth/forgot-password`. |
    | 429 | `rate_limited` | Signup, password reset, or other rate-limited endpoints are being hit too frequently from your IP. | Back off; the response includes `Retry-After`. |
    | 502 | `upstream_error` | An upstream provider (Stripe, dp-engine, SMTP) returned an error. | Retry with exponential backoff. Persistent 502s are a platform-side incident — capture the `X-Request-Id` and contact support. |
    | 503 | `service_unavailable` | A downstream service is down or unconfigured (e.g. SMTP missing creds in dev, Stripe disabled). | Same as 502 — back off and retry; on persistent failures, contact support with the `X-Request-Id`. |

    ## Stability

    Operations may change between releases without deprecation windows
    while the platform is in early-customer. Every breaking change ships a
    corresponding `info.version` bump; integration partners should pin to
    a version and watch the changelog.
  contact:
    name: DataVisuals
    email: sales@datavisuals.com
    url: https://datavisuals.com
  license:
    name: Proprietary
    url: https://trust.datavisuals.com/eula

servers:
  - url: https://dg.datavisuals.com/api
    description: Production
  - url: http://localhost:5001/api
    description: Local development

tags:
  - name: Auth
    description: |
      Login, logout, session check, signup, email verification, password reset,
      EULA acceptance. The session cookie pattern is described in **Conventions**.
  - name: Account
    description: Tenant entitlements view (the combined paywall + feature-flag surface).
  - name: Legal
    description: EULA version metadata. Public endpoint used by the signup form to
      stamp the version a user agreed to.
  - name: Decisions
    description: |
      The decision lifecycle — create, list, fetch, claim, finalize, defer,
      escalate, record action / outcome, edit anatomy stages, attach files,
      comment, and bulk-operate. The five-stage anatomy (Signal → Owner →
      Decision → Action → Outcome) is the authoring surface; PATCH endpoints
      exist for each editable stage.
  - name: Workflows
    description: |
      Workflow configuration (`config.yaml` per workflow), schema export,
      cross-workflow catalog, manual upload UI for `pipe_*` ingestion.
  - name: Dashboard
    description: Cross-workflow executive dashboard payload — operational rollups,
      apex section, DGI snapshot, recent outcomes.
  - name: Apex
    description: |
      Strategic decisions (Workflow 10) — apex decision queue, cross-workflow
      evidence linkage, board-cycle dossiers, board-action recording. Enterprise-only.
  - name: DGI
    description: Decision Governance Index — current score, historical trend,
      per-component contributing decisions.
  - name: Settings
    description: |
      Per-tenant configuration — institution profile, user management,
      workflow entitlement lifecycle, semantic-layer overrides.
  - name: System
    description: Health check.

security:
  - cookieAuth: []

components:
  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: session
      description: |
        HttpOnly session cookie set by `POST /auth/login` and `POST /auth/signup`.
        Carries an RS256-signed JWT with claims for tenant, role, and entitled
        workflows. 8-hour expiry; revoked via Valkey on logout.

  parameters:
    DecisionId:
      name: decision_id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: Decision UUID.
    AttachmentId:
      name: attachment_id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: Attachment UUID.
    WorkflowId:
      name: workflow_id
      in: path
      required: true
      schema: { type: string }
      description: Workflow slug (e.g. `02-bsa-fraud`).
    UserId:
      name: user_id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: User UUID.
    CycleId:
      name: cycle_id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: Board-cycle UUID.

  responses:
    BadRequest:
      description: Request validation failed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Session missing, expired, or revoked.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: |
        The session is valid but lacks the required entitlement, role, or
        workflow authority. May also appear as `409 eula_acceptance_required`
        on enforcing endpoints when the user's accepted EULA version is stale.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found, or not visible to the caller's tenant.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: State machine transition rejected, or duplicate resource.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    EulaRequired:
      description: |
        The user's accepted EULA version is older than the current version.
        Returned by every enforcing decorator. Clients should call
        `POST /auth/accept-eula` to unblock the session.
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/Error'
              - type: object
                properties:
                  current_version: { type: string, example: "v1.1" }
                  accepted_version: { type: string, nullable: true, example: "v1.0" }
                  eula_url: { type: string, format: uri, example: "https://trust.datavisuals.com/eula" }
    RateLimited:
      description: Rate limit exceeded.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BadGateway:
      description: Upstream provider (Stripe, dp-engine, SMTP) returned an error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServiceUnavailable:
      description: Downstream service unavailable or unconfigured (Stripe, SMTP).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Machine-readable error code.
          example: invalid_credentials
        message:
          type: string
          description: Human-readable description.
          example: Username or password is incorrect.
        details:
          type: object
          additionalProperties: true
          description: Optional structured context (field errors, etc.).

    User:
      type: object
      description: |
        Authenticated user payload (returned by `/auth/me`, `/auth/login`,
        `/auth/signup`, and `/settings/users`). The session-bearing variants
        also include entitled workflows and tenant context for the SPA.
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string }
        username: { type: string }
        email: { type: string, format: email }
        name: { type: string }
        title: { type: string, nullable: true }
        role:
          type: string
          enum: [admin, member, dv_admin]
          description: |
            `admin` — tenant administrator. `member` — standard tenant user.
            `dv_admin` — DataVisuals support-only super-user (parking-spot
            tenant). Most tenants only see `admin` and `member`.
        status:
          type: string
          enum: [active, inactive, locked, pending_invite]
        last_login_at: { type: string, format: date-time, nullable: true }
        login_count: { type: integer, example: 17 }
        email_verified: { type: boolean }
        eula_version: { type: string, nullable: true, example: "v1.1" }
        eula_accepted_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    SessionUser:
      type: object
      description: |
        Same fields as `User` plus the SPA-bootstrap context (tenant chrome,
        entitlements, sandbox flag). Returned by `/auth/me`, `/auth/login`,
        `/auth/signup`.
      allOf:
        - $ref: '#/components/schemas/User'
        - type: object
          properties:
            tenant_name: { type: string, example: "Riverdale Community Credit Union" }
            has_apex: { type: boolean, description: Whether the tenant's Enterprise apex entitlement is active. }
            is_sandbox: { type: boolean, description: True for free-trial sandbox tenants (chrome banner, watermark, suppressed DGI). }
            entitled_workflows:
              type: array
              items: { type: string }
              example: ["01-regulatory-compliance", "02-bsa-fraud"]
      example:
        id: "5d4f8e2a-3c1b-4a7e-9f8d-6e2b3c4f5a8d"
        tenant_id: "bayside"
        tenant_name: "Bayside Community Bank"
        username: "jordan@bayside.example"
        email: "jordan@bayside.example"
        name: "Jordan Reyes"
        title: "BSA Officer"
        role: "member"
        status: "active"
        last_login_at: "2026-05-06T14:32:18Z"
        login_count: 47
        email_verified: true
        eula_version: "v1.1"
        eula_accepted_at: "2026-04-12T09:15:42Z"
        has_apex: false
        is_sandbox: false
        entitled_workflows: ["01-regulatory-compliance", "02-bsa-fraud"]
        created_at: "2026-04-12T09:15:42Z"
        updated_at: "2026-05-06T14:32:18Z"

    Tenant:
      type: object
      description: Institution row + governance + unstructured profile.
      properties:
        tenant_id: { type: string, example: "riverdale" }
        name: { type: string, example: "Riverdale Community Credit Union" }
        org_type:
          type: string
          nullable: true
          enum: [CREDIT_UNION, COMMUNITY_BANK, SAVINGS_INSTITUTION]
        regulator:
          type: string
          nullable: true
          enum: [NCUA, FDIC, OCC, STATE]
        asset_size_band:
          type: string
          nullable: true
          enum: [small, medium, large, very_large]
        has_apex: { type: boolean }
        apex_started_at: { type: string, format: date-time, nullable: true }
        dgi_publication_opt_out: { type: boolean }
        is_sandbox: { type: boolean }
        tenant_profile:
          type: object
          description: |
            Unstructured config surface — peer group declaration, fiscal year
            start month, board cadence list, examiner contacts list, risk
            appetite statement.
          additionalProperties: true
        governance:
          $ref: '#/components/schemas/TenantGovernance'
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TenantGovernance:
      type: object
      properties:
        dual_control_enabled: { type: boolean }
        require_approval_roles:
          type: array
          items: { type: string }
          nullable: true
        updated_at: { type: string, format: date-time, nullable: true }
        updated_by: { type: string, format: uuid, nullable: true }

    Decision:
      type: object
      description: |
        Single decision row across all five anatomy stages. Optional fields
        appear only when the corresponding stage has been entered.
      required: [id, tenant_id, decision_number, workflow_id, decision_type, title, status, priority, created_at]
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string }
        decision_number: { type: string, example: "DG-2026-04-2118" }
        workflow_id: { type: string, example: "02-bsa-fraud" }
        decision_type: { type: string, example: "sar_filing_decision" }
        title: { type: string }
        description: { type: string, nullable: true }
        priority:
          type: string
          enum: [critical, urgent, high, medium, low]
        severity:
          type: string
          enum: [critical, high, medium, low]
          description: Impact-if-wrong; distinct from `priority` (urgency).
        status:
          type: string
          enum: [open, assigned, in_progress, decided, closed, deferred]
        sla_hours: { type: integer, nullable: true }
        sla_deadline: { type: string, format: date-time, nullable: true }
        sla_status: { type: string, nullable: true, enum: [on_track, warning, breach] }
        # Signal stage
        signal_captured_at: { type: string, format: date-time, nullable: true }
        signal_source:
          type: string
          nullable: true
          description: |
            For dp-engine signals: `dp-engine.{workflow_id}.{decision_type_id}.{metric_id}`.
            For manual creation: `manual.{user_id}.{uuid7}` (uniqueness preserves
            dedup invariant). Immutable after creation.
        signal_summary: { type: string, nullable: true }
        trigger_data:
          type: object
          additionalProperties: true
          nullable: true
        # Owner stage
        owner_id: { type: string, format: uuid, nullable: true }
        created_by: { type: string, format: uuid, nullable: true }
        assigned_at: { type: string, format: date-time, nullable: true }
        acknowledged_at: { type: string, format: date-time, nullable: true }
        owner_authority_level: { type: integer, nullable: true }
        escalation_path:
          type: array
          items: { type: object, additionalProperties: true }
          nullable: true
        # Decision stage
        decided_at: { type: string, format: date-time, nullable: true }
        selected_option: { type: string, nullable: true }
        alternatives:
          type: array
          items: { type: object, additionalProperties: true }
          nullable: true
        evidence_refs:
          type: array
          items: { type: object, additionalProperties: true }
          nullable: true
        rationale: { type: string, nullable: true }
        # Action stage
        action_started_at: { type: string, format: date-time, nullable: true }
        action_completed_at: { type: string, format: date-time, nullable: true }
        action_artifacts:
          type: array
          items: { type: object, additionalProperties: true }
          nullable: true
        # Outcome stage
        closed_at: { type: string, format: date-time, nullable: true }
        outcome_recorded_at: { type: string, format: date-time, nullable: true }
        outcome_text: { type: string, nullable: true }
        outcome_impact: { type: string, nullable: true }
        outcome_metrics:
          type: object
          additionalProperties: true
          nullable: true
        # Side states
        defer_reason: { type: string, nullable: true }
        defer_reopen_at: { type: string, format: date-time, nullable: true }
        escalated_to: { type: string, format: uuid, nullable: true }
        escalation_reason: { type: string, nullable: true }
        # Apex extensions (only populated on apex decisions or evidence-linked operationals)
        board_cycle_id: { type: string, format: uuid, nullable: true }
        target_outcome: { type: string, nullable: true }
        actual_outcome: { type: string, nullable: true }
        # Misc
        decision_payload:
          type: object
          additionalProperties: true
          nullable: true
        tags:
          type: array
          items: { type: string }
          nullable: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      example:
        id: "8c2a91d4-7bf2-4c15-b6e8-3a9f12c4d7e1"
        tenant_id: "bayside"
        decision_number: "DG-2026-05-1042"
        workflow_id: "02-bsa-fraud"
        decision_type: "sar_filing_decision"
        title: "Structuring pattern, $43,200 aggregate"
        description: "Eight cash deposits between $9,200 and $9,800 across three business days, single account."
        priority: "high"
        severity: "high"
        status: "decided"
        sla_hours: 96
        sla_deadline: "2026-05-08T17:00:00Z"
        sla_status: "on_track"
        signal_captured_at: "2026-05-04T08:14:00Z"
        signal_source: "dp-engine.02-bsa-fraud.sar_filing_decision.aggregate_cash_below_threshold"
        signal_summary: "Eight cash deposits below $10K reporting threshold across three days, one customer."
        trigger_data:
          aggregate_amount: 43200
          deposit_count: 8
          window_days: 3
          account_id: "MEM-78214"
        owner_id: "5d4f8e2a-3c1b-4a7e-9f8d-6e2b3c4f5a8d"
        created_by: "5d4f8e2a-3c1b-4a7e-9f8d-6e2b3c4f5a8d"
        assigned_at: "2026-05-04T09:02:11Z"
        acknowledged_at: "2026-05-04T09:02:11Z"
        owner_authority_level: 3
        escalation_path:
          - { user_id: "8a1c...", role: "compliance_manager", level: 4 }
          - { user_id: "2f7e...", role: "chief_risk_officer", level: 5 }
        decided_at: "2026-05-04T15:48:33Z"
        selected_option: "file_sar"
        rationale: "Pattern matches structuring typology in BSA examination manual §VII.A. Customer has no commercial cash-handling activity that explains sub-threshold pacing."
        alternatives:
          - { id: "continue_monitoring", reason_rejected: "Aggregate already exceeds reportable threshold; further monitoring would risk filing-window breach." }
          - { id: "close_no_sar", reason_rejected: "Pattern strength too high to disposition without filing." }
        evidence_refs:
          - { type: "transaction_log", ref: "TXN-2026-04-29-3318" }
          - { type: "kyc_record", ref: "MEM-78214" }
        action_started_at: null
        action_completed_at: null
        action_artifacts: null
        closed_at: null
        outcome_recorded_at: null
        outcome_text: null
        decision_payload:
          aggregate_amount: 43200
          account_ids: ["MEM-78214"]
          pattern_type: "structuring"
        tags: ["structuring", "cash"]
        created_at: "2026-05-04T08:14:00Z"
        updated_at: "2026-05-04T15:48:33Z"

    DecisionEvent:
      type: object
      description: |
        One row in the append-only event log for a decision. Drives both
        the audit trail and the comment thread (when `action='comment'`).
      properties:
        id: { type: string, format: uuid }
        decision_id: { type: string, format: uuid }
        tenant_id: { type: string }
        workflow_id: { type: string }
        user_id: { type: string, format: uuid, nullable: true }
        action:
          type: string
          description: |
            Event verb. Common values: `created`, `claim`, `finalize`,
            `defer`, `escalate`, `action`, `outcome`, `stage_edit`,
            `comment`, `attachment_added`, `attachment_removed`,
            `evidence_linked`, `evidence_superseded`, `board_action`.
        old_status:
          type: string
          nullable: true
          enum: [open, assigned, in_progress, decided, closed, deferred]
        new_status:
          type: string
          nullable: true
          enum: [open, assigned, in_progress, decided, closed, deferred]
        comment:
          type: string
          nullable: true
          description: Body text — used by `action='comment'` and free-form notes.
        metadata:
          type: object
          additionalProperties: true
          description: |
            Context-specific fields. For comments — `{stage_context, mentions}`.
            For bulk operations — `{bulk_operation_id}`. For stage edits —
            `{diff: {field: {old, new}, ...}}`.
        created_at: { type: string, format: date-time }
      example:
        id: "f3c8b1a4-2e9d-4f81-9c7b-8d6e3a1f2b4c"
        decision_id: "8c2a91d4-7bf2-4c15-b6e8-3a9f12c4d7e1"
        tenant_id: "bayside"
        workflow_id: "02-bsa-fraud"
        user_id: "5d4f8e2a-3c1b-4a7e-9f8d-6e2b3c4f5a8d"
        action: "comment"
        old_status: null
        new_status: null
        comment: "Pulling 90-day transaction history before finalizing — pinged Alex on the wire side."
        metadata:
          stage_context: "decision"
          mentions: ["7e2c9b5d-1f4a-4d3e-8b9c-2a1f5e6d7c4b"]
        created_at: "2026-05-04T11:18:42Z"

    Attachment:
      type: object
      properties:
        id: { type: string, format: uuid }
        decision_id: { type: string, format: uuid }
        stage:
          type: string
          enum: [signal, decision, action, outcome, evidence]
        filename: { type: string }
        content_type: { type: string, example: "application/pdf" }
        size_bytes: { type: integer, format: int64 }
        storage_backend:
          type: string
          enum: [do_spaces, pg_lo, local]
          description: Backend recorded so a future migration can identify the source.
        uploaded_by: { type: string, format: uuid }
        uploaded_at: { type: string, format: date-time }
        description: { type: string, nullable: true }
        superseded_at:
          type: string
          format: date-time
          nullable: true
          description: Soft-delete timestamp. Active attachments have null.
      example:
        id: "9b1c4e7a-3f2d-4a8e-b9c1-7d6e2a4f5b8c"
        decision_id: "8c2a91d4-7bf2-4c15-b6e8-3a9f12c4d7e1"
        stage: "evidence"
        filename: "txn-history-MEM-78214-2026-04.pdf"
        content_type: "application/pdf"
        size_bytes: 184231
        storage_backend: "do_spaces"
        uploaded_by: "5d4f8e2a-3c1b-4a7e-9f8d-6e2b3c4f5a8d"
        uploaded_at: "2026-05-04T13:22:08Z"
        description: "90-day transaction extract for the structuring-pattern account."
        superseded_at: null

    Workflow:
      type: object
      description: Workflow listing entry (subset of full config).
      properties:
        id: { type: string, example: "02-bsa-fraud" }
        name: { type: string, example: "BSA & Fraud" }
        description: { type: string, nullable: true }
        version: { type: string, example: "1.0" }
        icon: { type: string, nullable: true }
        color: { type: string, nullable: true, example: "#e11d48" }
        apex_contributing: { type: boolean }
        is_apex: { type: boolean }

    WorkflowConfig:
      type: object
      description: |
        Full merged workflow config (base + tenant overrides). The shape is
        defined by `dgp-workflow-yaml-schema-v1_0.md`. Nested blocks include
        `metrics`, `decision_types`, `authority_levels`, `personas`,
        `alert_categories`, `segments`, and `overridable_keys`.
      additionalProperties: true

    Entitlement:
      type: object
      description: |
        Combined billing + feature-flag view. One row per tenant; the
        billing master that drives Stripe sync.
      properties:
        tenant_id: { type: string }
        plan_tier:
          type: string
          enum: [free, starter, team, business, enterprise]
        workflows_allowed: { type: integer, example: 3 }
        workflows_active: { type: integer, example: 2 }
        users_allowed: { type: integer, example: 10 }
        features:
          type: object
          description: Tier-specific feature flags (sandbox, apex, watermark, sso, etc.).
          additionalProperties: { type: boolean }
        status:
          type: string
          enum: [active, past_due, trialing, canceled]
        trial_ends_at: { type: string, format: date-time, nullable: true }
        current_period_end: { type: string, format: date-time, nullable: true }
        user_email_verified: { type: boolean }
      example:
        tenant_id: "bayside"
        plan_tier: "team"
        workflows_allowed: 3
        workflows_active: 2
        users_allowed: 10
        features:
          sandbox: false
          apex: false
          watermark: false
          sso: false
        status: "active"
        trial_ends_at: null
        current_period_end: "2026-06-12T00:00:00Z"
        user_email_verified: true

    TenantEntitlement:
      type: object
      description: |
        Per-workflow entitlement grant — one row per (tenant, workflow,
        grant). Append-only; revocation sets `ended_at` rather than
        deleting the row.
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string }
        workflow_id: { type: string }
        status:
          type: string
          enum: [trial, active, suspended, expired]
        pricing_tier: { type: string, nullable: true }
        started_at: { type: string, format: date-time, nullable: true }
        ended_at: { type: string, format: date-time, nullable: true }
        notes: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

    SemanticOverride:
      type: object
      description: One row in the append-only override history.
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string }
        workflow_id: { type: string }
        config_key:
          type: string
          description: Dot-notation path into the workflow config.
          example: "thresholds.sar_timeliness_target"
        config_value:
          description: Override value (any JSON-serializable type).
        modified_by: { type: string, format: uuid }
        modified_at: { type: string, format: date-time }
        approved_by: { type: string, format: uuid, nullable: true }
        approved_at: { type: string, format: date-time, nullable: true }
        change_note: { type: string, nullable: true }
        superseded_at: { type: string, format: date-time, nullable: true }

    DgiMeasurement:
      type: object
      description: |
        One DGI measurement period. Computed nightly per tenant; methodology
        version is recorded so historical scores remain reproducible across
        methodology changes.
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string }
        measurement_period_start: { type: string, format: date }
        measurement_period_end: { type: string, format: date }
        computed_at: { type: string, format: date-time }
        dgi_score: { type: number, format: float, example: 73.4 }
        components:
          type: object
          description: Per-component scores. Keys are `ownership_clarity`,
            `cycle_time_discipline`, `documented_rationale`, `outcome_capture`,
            `authority_appropriateness`, `apex_linkage` (apex tenants only).
          additionalProperties: { type: number }
        peer_percentile: { type: number, format: float, nullable: true }
        methodology_version: { type: string, example: "v1.0" }
        total_consequential_decisions: { type: integer }
        decisions_with_named_owner: { type: integer }
        decisions_within_sla: { type: integer }
        decisions_with_documented_rationale: { type: integer }
      example:
        id: "b4d7e2c1-8a3f-4e9d-b6c5-2a1f7e8d9c4b"
        tenant_id: "bayside"
        measurement_period_start: "2026-02-01"
        measurement_period_end: "2026-04-30"
        computed_at: "2026-05-01T03:14:22Z"
        dgi_score: 73.4
        components:
          ownership_clarity: 84.2
          cycle_time_discipline: 71.8
          documented_rationale: 76.5
          outcome_capture: 58.3
          authority_appropriateness: 81.0
        peer_percentile: 64.0
        methodology_version: "v1.0"
        total_consequential_decisions: 142
        decisions_with_named_owner: 138
        decisions_within_sla: 102
        decisions_with_documented_rationale: 109

    ApexEvidenceLink:
      type: object
      description: Many-to-many link from an apex decision to an operational
        decision serving as evidence.
      properties:
        id: { type: string, format: uuid }
        apex_decision_id: { type: string, format: uuid }
        operational_decision_id: { type: string, format: uuid }
        evidence_role:
          type: string
          enum: [primary_evidence, supporting, risk_concentration, regulatory_alignment, outcome, counter]
        link_rationale: { type: string, nullable: true }
        linked_at: { type: string, format: date-time }
        linked_by: { type: string, format: uuid }
        superseded_at: { type: string, format: date-time, nullable: true }
        superseded_by_link_id: { type: string, format: uuid, nullable: true }
        operational:
          type: object
          description: Snapshot of the operational decision being cited.
          properties:
            id: { type: string, format: uuid }
            decision_number: { type: string }
            title: { type: string }
            workflow_id: { type: string }
            status: { type: string }
            decided_at: { type: string, format: date-time, nullable: true }
      example:
        id: "c7e9f2a4-1b8d-4e3c-9f6a-2d4b7c8e1f5a"
        apex_decision_id: "a1f3b6e8-9c2d-4f7e-b1a4-5c8d3e6f2a9b"
        operational_decision_id: "8c2a91d4-7bf2-4c15-b6e8-3a9f12c4d7e1"
        evidence_role: "risk_concentration"
        link_rationale: "Recent SAR filings against high-risk-relationship cohort inform the proposed risk-appetite tightening for cash-intensive accounts."
        linked_at: "2026-04-22T16:08:11Z"
        linked_by: "2f7e9b1c-4a8d-4e3f-b6c2-9a1d4f7e8c3b"
        superseded_at: null
        superseded_by_link_id: null
        operational:
          id: "8c2a91d4-7bf2-4c15-b6e8-3a9f12c4d7e1"
          decision_number: "DG-2026-05-1042"
          title: "Structuring pattern, $43,200 aggregate"
          workflow_id: "02-bsa-fraud"
          status: "decided"
          decided_at: "2026-05-04T15:48:33Z"

    BoardCycle:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string }
        name: { type: string, example: "Q2 2026 Board Cycle" }
        cycle_type: { type: string, example: "full_board" }
        committee: { type: string, nullable: true }
        cycle_start: { type: string, format: date }
        cycle_end: { type: string, format: date }
        status:
          type: string
          enum: [planned, active, closed]
        meeting_date: { type: string, format: date, nullable: true }
        notes: { type: string, nullable: true }
        decision_count:
          type: integer
          nullable: true
          description: Open or in-flight decisions tied to this cycle (when included by the listing endpoint).
        created_at: { type: string, format: date-time }
        created_by: { type: string, format: uuid }

    BulkOperationResult:
      type: object
      description: |
        Standard envelope for `POST /decisions/bulk/*` endpoints. Per-decision
        failures are reported in `results`, not as a request-level error.
      properties:
        bulk_operation_id:
          type: string
          format: uuid
          description: Correlation key — written into each affected `decision_event.metadata.bulk_operation_id` for audit reconstruction.
        results:
          type: array
          items:
            type: object
            properties:
              decision_id: { type: string, format: uuid }
              ok: { type: boolean }
              error: { type: string, nullable: true }
        succeeded: { type: integer }
        failed: { type: integer }
      example:
        bulk_operation_id: "e8f1c4a7-2b9d-4e6c-8f1a-3d7e2c4b9f8a"
        results:
          - { decision_id: "8c2a91d4-7bf2-4c15-b6e8-3a9f12c4d7e1", ok: true }
          - { decision_id: "9d3b82e5-8cf3-4d26-c7f9-4ba023d5e8f2", ok: true }
          - { decision_id: "a4e293f6-9df4-4e37-d8a0-5cb134e6f9a3", ok: false, error: "insufficient_authority" }
        succeeded: 2
        failed: 1

paths:
  # ───────────────────────────────────────────────────────────
  # AUTH
  # ───────────────────────────────────────────────────────────
  /auth/login:
    post:
      tags: [Auth]
      summary: Log in with username + password
      description: |
        Validates credentials, issues an RS256-signed session JWT in the
        `session` HttpOnly cookie, and returns the full session-bootstrap
        payload (user + tenant chrome + entitled workflows).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username: { type: string, example: "jamie@riverdale.org" }
                password: { type: string, format: password }
      responses:
        "200":
          description: Authenticated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionUser' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }

  /auth/logout:
    post:
      tags: [Auth]
      summary: End current session
      description: Adds the JWT's `jti` to the Valkey revocation set and clears the cookie.
      security: []
      responses:
        "200":
          description: Logged out.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string } } }

  /auth/me:
    get:
      tags: [Auth]
      summary: Current session payload
      description: |
        Returns the `SessionUser` for the cookie-bearing user. Lenient auth —
        does **not** short-circuit on stale EULA so the UI can render the
        re-acceptance modal.
      responses:
        "200":
          description: Session is valid.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionUser' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }

  /auth/session:
    get:
      tags: [Auth]
      summary: Lightweight session health check
      description: Cheap probe used by the SPA's auth bootstrap. Lenient auth.
      responses:
        "200":
          description: Session is valid.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: "ok" } } }
        "401": { $ref: '#/components/responses/Unauthorized' }

  /auth/signup:
    post:
      tags: [Auth]
      summary: Self-serve signup
      description: |
        Atomic signup transaction. Creates `tenants` + admin `users` + default
        Free-tier `entitlements` row, sends the email-verification mail,
        records EULA acceptance, and returns the session payload (cookie
        already set).

        Rate-limited; honeypot field `hp_field` must be empty.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, name, institution_name, eula_accepted, eula_version]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
                name: { type: string }
                institution_name: { type: string, example: "Bayside Community Bank" }
                eula_accepted:
                  type: boolean
                  enum: [true]
                  description: Must be `true`. The Zod gate on the form enforces this client-side.
                eula_version:
                  type: string
                  description: |
                    The EULA version the user is accepting. Must equal the
                    server's `EULA_CURRENT_VERSION` — a stale value (form was
                    open across a deploy) is rejected with 400 so we never
                    record acceptance against unread text.
                  example: "v1.1"
                hp_field:
                  type: string
                  description: Honeypot. Must be absent or empty.
      responses:
        "201":
          description: Account created and signed in.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SessionUser' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "409":
          description: Email already in use, or stale EULA version.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        "429": { $ref: '#/components/responses/RateLimited' }
        "500": { $ref: '#/components/responses/InternalError' }

  /auth/verify-email:
    get:
      tags: [Auth]
      summary: Consume email-verification token
      description: |
        Single-use token issued at signup. Marks `users.email_verified = true`.
        Tokens are signed and time-bounded; consumed tokens 404.
      security: []
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Verified.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: "verified" } } }
        "400": { $ref: '#/components/responses/BadRequest' }
        "404": { $ref: '#/components/responses/NotFound' }
        "503": { $ref: '#/components/responses/ServiceUnavailable' }

  /auth/resend-verification:
    post:
      tags: [Auth]
      summary: Mint a fresh verification email
      description: Issues a new token and sends it via SMTP.
      responses:
        "200":
          description: Sent.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: "sent" } } }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }
        "503": { $ref: '#/components/responses/ServiceUnavailable' }

  /auth/forgot-password:
    post:
      tags: [Auth]
      summary: Request a password-reset email
      description: |
        Always returns 200 — does not disclose whether the email is registered.
        `dv_admin` accounts skip the email send (the address may be a
        non-receiving alias; recovery is admin-tooled).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Request received (email may or may not have been sent).
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: "ok" } } }

  /auth/reset-password:
    post:
      tags: [Auth]
      summary: Consume reset token + set new password
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: { type: string }
                password: { type: string, format: password }
      responses:
        "200":
          description: Password changed.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string } } }
        "400": { $ref: '#/components/responses/BadRequest' }
        "404": { $ref: '#/components/responses/NotFound' }
        "503": { $ref: '#/components/responses/ServiceUnavailable' }

  /auth/accept-eula:
    post:
      tags: [Auth]
      summary: Record acceptance of the current EULA
      description: |
        Unblocks a session that was short-circuited by the re-prompt-on-revision
        middleware. The server records its own `EULA_CURRENT_VERSION` — clients
        cannot bypass with a stale value. Lenient auth.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                eula_version:
                  type: string
                  description: Optional — informational only; server stamps the canonical version.
      responses:
        "200":
          description: Accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "accepted" }
                  eula_version: { type: string }
                  eula_accepted_at: { type: string, format: date-time }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────────────────────────────────────
  # ACCOUNT
  # ───────────────────────────────────────────────────────────
  /account/entitlements:
    get:
      tags: [Account]
      summary: Combined entitlement view
      description: |
        Single-row read of the tenant's billing + feature-flag state. Used by
        the SPA's paywall component and by gating decorators
        (`@require_min_tier`, `@require_feature`). The webhook receiver at
        `POST /webhooks/stripe` is the source of truth for state changes.
      responses:
        "200":
          description: Entitlement snapshot.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Entitlement' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "409": { $ref: '#/components/responses/EulaRequired' }

  # ───────────────────────────────────────────────────────────
  # LEGAL
  # ───────────────────────────────────────────────────────────
  /legal/eula-version:
    get:
      tags: [Legal]
      summary: Current EULA version + canonical URL
      description: |
        Public endpoint consumed by the signup form so it can stamp the
        version number the user is agreeing to. The canonical text lives
        at `trust.datavisuals.com/eula`.
      security: []
      responses:
        "200":
          description: Current version metadata.
          content:
            application/json:
              schema:
                type: object
                required: [version, url]
                properties:
                  version: { type: string, example: "v1.1" }
                  url: { type: string, format: uri, example: "https://trust.datavisuals.com/eula" }

  # ───────────────────────────────────────────────────────────
  # DECISIONS — list + create
  # ───────────────────────────────────────────────────────────
  /decisions:
    get:
      tags: [Decisions]
      summary: List decisions (the Inbox query)
      description: |
        Drives the Inbox surface. Returns paginated decisions filtered by
        scope, density, stage, and workflow. Default sort is severity DESC,
        SLA deadline ASC nulls-last, created_at DESC tiebreak.
      parameters:
        - name: scope
          in: query
          description: |
            `all_entitled` (default) — every decision the user can see across
            entitled workflows. `assigned` — only decisions where the caller
            is `owner_id`. `created` — only decisions where the caller is
            `created_by` (manual-creation history).
          schema:
            type: string
            enum: [all_entitled, assigned, created]
            default: all_entitled
        - name: density
          in: query
          description: |
            `active` (default) hides `decided` and `closed` rows; the SPA
            renders those in a collapsed "Recently decided" section.
            `all` returns every status.
          schema:
            type: string
            enum: [active, all]
            default: active
        - name: stage
          in: query
          schema:
            type: string
            enum: [signal, owner, decision, action, outcome]
        - name: workflow_id
          in: query
          schema: { type: string }
        - name: sort
          in: query
          schema: { type: string }
          description: Sort key override (e.g. `created_at:desc`).
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Paginated decisions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Decision' }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
                  scope: { type: string }
                  density: { type: string }
                  stage: { type: string, nullable: true }
                  workflow_id: { type: string, nullable: true }
                  sort: { type: string, nullable: true }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "409": { $ref: '#/components/responses/EulaRequired' }

    post:
      tags: [Decisions]
      summary: Create a manual decision
      description: |
        Manual creation drawer (Phase 7.5). Authority-gated per decision type's
        `manually_creatable` + `authoring_authority_level` in the workflow YAML.
        Server overrides any client-supplied `signal_source` to
        `manual.{user_id}.{uuid7}` to preserve the dp-engine dedup invariant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workflow_id, decision_type, title]
              properties:
                workflow_id: { type: string }
                decision_type: { type: string }
                title: { type: string }
                description: { type: string }
                owner_id: { type: string, format: uuid, nullable: true }
                priority:
                  type: string
                  enum: [critical, urgent, high, medium, low]
                sla_hours: { type: integer }
                trigger_data: { type: object, additionalProperties: true }
                signal_summary: { type: string }
                decision_payload: { type: object, additionalProperties: true }
                tags:
                  type: array
                  items: { type: string }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403":
          description: |
            User lacks `authoring_authority_level` for this decision type, or
            the type is not `manually_creatable`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        "409": { $ref: '#/components/responses/EulaRequired' }

  # ───────────────────────────────────────────────────────────
  # DECISIONS — single decision lifecycle
  # ───────────────────────────────────────────────────────────
  /decisions/{decision_id}:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    get:
      tags: [Decisions]
      summary: Fetch a single decision (full anatomy)
      responses:
        "200":
          description: Decision row across all five anatomy stages.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /decisions/{decision_id}/export:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    get:
      tags: [Decisions]
      summary: Export decision record as PDF
      description: |
        ReportLab-rendered examiner record with the full five-stage anatomy.
        Sandbox tenants render with a "SANDBOX — DEMO DATA" watermark.
      parameters:
        - name: format
          in: query
          schema:
            type: string
            enum: [pdf]
            default: pdf
      responses:
        "200":
          description: PDF download.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /decisions/{decision_id}/events:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    get:
      tags: [Decisions]
      summary: List decision events (audit trail + comments)
      description: |
        The append-only event log — both the audit trail and the comment
        thread share this stream, distinguished by `action`.
      parameters:
        - name: include
          in: query
          description: |
            `audit` (default) — excludes `action='comment'`. `comments` —
            only comment events. `all` — everything.
          schema:
            type: string
            enum: [audit, comments, all]
            default: audit
      responses:
        "200":
          description: Event list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/DecisionEvent' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /decisions/{decision_id}/claim:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    post:
      tags: [Decisions]
      summary: Claim ownership
      description: |
        Snapshots `owner_authority_level` to the decision row at claim time
        and computes `escalation_path` from `user_supervisors`. State
        transitions to `in_progress`.
      responses:
        "200":
          description: Updated decision (`status='in_progress'`, owner set).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "409":
          description: Decision is already owned, deferred, or in a terminal state.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /decisions/{decision_id}/finalize:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    post:
      tags: [Decisions]
      summary: Finalize the decision stage
      description: |
        Records `selected_option` + `rationale` + `evidence_refs`,
        validates authority via `user_can_approve_action`, transitions
        the decision to `decided`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [selected_option, rationale]
              properties:
                selected_option: { type: string, example: "file_sar" }
                rationale: { type: string }
                evidence_refs:
                  type: array
                  items: { type: object, additionalProperties: true }
                alternatives:
                  type: array
                  items: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Decision finalized.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
        "409":
          description: Decision is in a state that cannot be finalized.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /decisions/{decision_id}/defer:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    post:
      tags: [Decisions]
      summary: Defer the decision
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string }
                reopen_at: { type: string, format: date-time, nullable: true }
      responses:
        "200":
          description: Deferred (status='deferred').
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "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' }

  /decisions/{decision_id}/escalate:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    post:
      tags: [Decisions]
      summary: Escalate to another user / supervisor
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string }
                escalated_to:
                  type: string
                  format: uuid
                  description: Optional explicit user. If omitted, the next supervisor in the snapshot escalation_path is used.
      responses:
        "200":
          description: Escalation recorded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "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' }

  /decisions/{decision_id}/action:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    post:
      tags: [Decisions]
      summary: Record action execution
      description: Stage 4 transition — start/finish timestamps + artifact references.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                started_at: { type: string, format: date-time }
                completed_at: { type: string, format: date-time }
                artifacts:
                  type: array
                  items: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Action stage updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "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' }
    patch:
      tags: [Decisions]
      summary: Edit action stage fields (post-transition)
      description: |
        PATCH covers post-transition edits to the action stage. The POST
        on the same path is the transition trigger; the two coexist by
        design (per `dgp-authoring-and-config-amendment-v1_2.md` §8).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                action_started_at: { type: string, format: date-time }
                action_completed_at: { type: string, format: date-time }
                action_artifacts:
                  type: array
                  items: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "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' }

  /decisions/{decision_id}/outcome:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    post:
      tags: [Decisions]
      summary: Record outcome (closes the decision)
      description: Stage 5 transition. Sets `outcome_recorded_at` and `status='closed'`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [outcome_text]
              properties:
                outcome_text: { type: string }
                outcome_impact: { type: string }
                outcome_metrics: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Outcome recorded; decision closed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "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' }
    patch:
      tags: [Decisions]
      summary: Edit outcome stage fields without affecting closed status
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                outcome_text: { type: string }
                outcome_impact: { type: string }
                outcome_metrics: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "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' }

  /decisions/{decision_id}/signal:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    patch:
      tags: [Decisions]
      summary: Edit signal stage fields
      description: |
        Signal stage edit. `signal_source` is intentionally immutable —
        editing it would break audit-trail integrity and the dp-engine
        dedup invariant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                signal_summary: { type: string }
                trigger_data: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "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' }

  /decisions/{decision_id}/decision:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    patch:
      tags: [Decisions]
      summary: Edit decision stage fields without finalizing
      description: |
        Edits stage-3 fields (selected_option, rationale, evidence_refs,
        alternatives) without advancing status. Use `POST /finalize` to
        actually transition.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                selected_option: { type: string }
                alternatives:
                  type: array
                  items: { type: object, additionalProperties: true }
                evidence_refs:
                  type: array
                  items: { type: object, additionalProperties: true }
                rationale: { type: string }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Decision' }
        "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' }

  # ───────────────────────────────────────────────────────────
  # DECISIONS — attachments
  # ───────────────────────────────────────────────────────────
  /decisions/{decision_id}/attachments:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    get:
      tags: [Decisions]
      summary: List active attachments
      responses:
        "200":
          description: Attachments where `superseded_at IS NULL`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/Attachment' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Decisions]
      summary: Upload an attachment
      description: |
        Multipart upload routed through the `AttachmentStorage` abstraction.
        Production uses DO Spaces (`storage_backend='do_spaces'`); local dev
        uses the filesystem. Writes a `decision_event` with
        `action='attachment_added'`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                stage:
                  type: string
                  enum: [signal, decision, action, outcome, evidence]
                  default: evidence
                description: { type: string }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Attachment' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /decisions/{decision_id}/attachments/{attachment_id}:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
      - $ref: '#/components/parameters/AttachmentId'
    delete:
      tags: [Decisions]
      summary: Soft-delete an attachment
      description: Sets `superseded_at`; row is retained for audit.
      responses:
        "200":
          description: Marked superseded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  superseded: { type: boolean, example: true }
                  attachment_id: { type: string, format: uuid }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /decisions/{decision_id}/attachments/{attachment_id}/download:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
      - $ref: '#/components/parameters/AttachmentId'
    get:
      tags: [Decisions]
      summary: Download attachment bytes
      description: |
        For backends that mint signed URLs (DO Spaces) the response is a
        302 redirect. For backends that don't (PG large objects, local FS)
        the bytes stream inline.
      responses:
        "200":
          description: Inline file stream.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "302":
          description: Redirect to signed URL.
          headers:
            Location:
              schema: { type: string, format: uri }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────────────────────────────────────
  # DECISIONS — comments
  # ───────────────────────────────────────────────────────────
  /decisions/{decision_id}/comments:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    post:
      tags: [Decisions]
      summary: Post a comment
      description: |
        Stored as a `decision_event` with `action='comment'`. The convention
        keeps comments and audit trail in one stream (no separate comments
        table). Use `GET /decisions/{decision_id}/events?include=comments`
        to fetch them.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
                mentions:
                  type: array
                  items: { type: string, format: uuid }
                  description: User IDs being @-mentioned.
                stage_context:
                  type: string
                  enum: [signal, decision, action, outcome, general]
                  default: general
                  description: Which anatomy stage section the comment renders under.
      responses:
        "201":
          description: Comment posted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DecisionEvent' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────────────────────────────────────
  # DECISIONS — bulk
  # ───────────────────────────────────────────────────────────
  /decisions/bulk/claim:
    post:
      tags: [Decisions]
      summary: Bulk-claim decisions
      description: |
        Multi-select Inbox triage. Per-decision failures (authority,
        already-claimed, missing) are returned in `results` rather than
        as a request-level error. Each affected decision_event metadata
        carries the `bulk_operation_id` for audit reconstruction.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision_ids]
              properties:
                decision_ids:
                  type: array
                  maxItems: 200
                  items: { type: string, format: uuid }
      responses:
        "200":
          description: Per-decision results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkOperationResult' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }

  /decisions/bulk/comment:
    post:
      tags: [Decisions]
      summary: Bulk-post the same comment across decisions
      description: |
        Comment is posted with `stage_context='general'`. Per-decision
        authority is checked individually; the same `bulk_operation_id`
        is threaded through every successful event.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision_ids, body]
              properties:
                decision_ids:
                  type: array
                  maxItems: 200
                  items: { type: string, format: uuid }
                body: { type: string }
      responses:
        "200":
          description: Per-decision results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkOperationResult' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }

  # ───────────────────────────────────────────────────────────
  # WORKFLOWS
  # ───────────────────────────────────────────────────────────
  /workflows:
    get:
      tags: [Workflows]
      summary: List entitled workflows
      description: |
        Returns every workflow the caller's tenant is entitled to (per
        `tenant_entitlements`). Drives the rail nav and the workflow chip
        registry in the SPA.
      responses:
        "200":
          description: Entitled workflows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  workflows:
                    type: array
                    items: { $ref: '#/components/schemas/Workflow' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "409": { $ref: '#/components/responses/EulaRequired' }

  /workflows/{workflow_id}/config:
    parameters:
      - $ref: '#/components/parameters/WorkflowId'
    get:
      tags: [Workflows]
      summary: Full merged workflow config
      description: |
        Returns the workflow's `config.yaml` after applying tenant
        overrides via the `ConfigMerger`. Drives the Schema Explorer and
        the runtime semantic engine.
      responses:
        "200":
          description: Merged config object.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowConfig' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /workflows/{workflow_id}/schema/export:
    parameters:
      - $ref: '#/components/parameters/WorkflowId'
    get:
      tags: [Workflows]
      summary: Download workflow config as YAML or JSON
      parameters:
        - name: format
          in: query
          schema:
            type: string
            enum: [yaml, json]
            default: yaml
      responses:
        "200":
          description: File download.
          content:
            application/yaml:
              schema:
                type: string
            application/json:
              schema: { $ref: '#/components/schemas/WorkflowConfig' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /workflows/{workflow_id}/upload/sample.csv:
    parameters:
      - $ref: '#/components/parameters/WorkflowId'
    get:
      tags: [Workflows]
      summary: Download a sample upload CSV
      description: |
        Returns a CSV with headers + example values derived from the
        workflow's upload schema. Used by the manual upload UI to give
        operators a starting template.
      responses:
        "200":
          description: Sample CSV.
          content:
            text/csv:
              schema: { type: string }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /workflows/{workflow_id}/upload:
    parameters:
      - $ref: '#/components/parameters/WorkflowId'
    post:
      tags: [Workflows]
      summary: Manual upload (CSV or JSON) to a workflow
      description: |
        Validates rows against the workflow's upload schema and writes
        them to the `pipe_*` table for that workflow. In production,
        the same payload is also forwarded to dp-engine's existing
        `POST /upload/api/v1/upload/<workflow>` API for parity. Inline
        validation errors return 400 with structured details.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: CSV or JSON payload.
      responses:
        "200":
          description: Upload accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows_written: { type: integer }
                  mode:
                    type: string
                    enum: [direct, dp_engine_proxy]
                  pipe_table: { type: string, nullable: true }
                  dp_engine_status: { type: string, nullable: true }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "502": { $ref: '#/components/responses/BadGateway' }
        "503": { $ref: '#/components/responses/ServiceUnavailable' }

  /catalog/export:
    get:
      tags: [Workflows]
      summary: Download aggregated multi-workflow catalog
      description: |
        Cross-workflow Semantic Catalog export. Drops unentitled or
        unknown workflow IDs silently per the discovery-surface framing
        (the response is always a valid catalog, never errors on missing IDs).
      parameters:
        - name: format
          in: query
          schema:
            type: string
            enum: [yaml, json]
            default: yaml
        - name: workflow_ids
          in: query
          description: Comma-separated workflow slugs. Default — every entitled workflow.
          schema: { type: string }
      responses:
        "200":
          description: Catalog file.
          content:
            application/yaml:
              schema:
                type: string
            application/json:
              schema:
                type: object
                properties:
                  catalog:
                    type: object
                    properties:
                      version: { type: string, example: "1.0" }
                      workflows:
                        type: array
                        items: { $ref: '#/components/schemas/WorkflowConfig' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }

  # ───────────────────────────────────────────────────────────
  # DASHBOARD
  # ───────────────────────────────────────────────────────────
  /dashboard:
    get:
      tags: [Dashboard]
      summary: Cross-workflow executive dashboard
      description: |
        Single-payload view used by `/dashboard` in the SPA. Combines
        operational rollups (per workflow + totals + sparklines), the
        Apex section (open decisions by domain, current cycle), the
        DGI snapshot (current + previous + trend direction), and a
        recent-outcomes list across workflows.
      responses:
        "200":
          description: Dashboard payload.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      has_apex: { type: boolean }
                  operational:
                    type: object
                    properties:
                      by_workflow:
                        type: array
                        items:
                          type: object
                          properties:
                            workflow_id: { type: string }
                            workflow_name: { type: string }
                            color: { type: string }
                            open_count: { type: integer }
                            decided_count: { type: integer }
                            at_risk_count: { type: integer }
                            trend:
                              type: array
                              items:
                                type: object
                                properties:
                                  week_starting: { type: string, format: date }
                                  opened_count: { type: integer }
                      totals:
                        type: object
                        properties:
                          open: { type: integer }
                          decided: { type: integer }
                          at_risk: { type: integer }
                  apex:
                    type: object
                    properties:
                      has_apex: { type: boolean }
                      open_decisions_count: { type: integer }
                      open_by_domain:
                        type: array
                        items:
                          type: object
                          properties:
                            domain_id: { type: string }
                            name: { type: string }
                            primary_owner_role: { type: string }
                            open_count: { type: integer }
                      current_cycle:
                        type: object
                        nullable: true
                        properties:
                          id: { type: string, format: uuid }
                          name: { type: string }
                          cycle_end: { type: string, format: date }
                          open_count: { type: integer }
                  dgi:
                    type: object
                    properties:
                      score: { type: number, nullable: true }
                      previous_score: { type: number, nullable: true }
                      trend:
                        type: string
                        enum: [up, flat, down]
                      computed_at: { type: string, format: date-time, nullable: true }
                  recent_outcomes:
                    type: array
                    items:
                      type: object
                      properties:
                        decision_id: { type: string, format: uuid }
                        decision_number: { type: string }
                        title: { type: string }
                        workflow_id: { type: string }
                        workflow_name: { type: string }
                        workflow_color: { type: string }
                        outcome_recorded_at: { type: string, format: date-time }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }
        "409": { $ref: '#/components/responses/EulaRequired' }

  # ───────────────────────────────────────────────────────────
  # APEX — Enterprise-tier strategic decisions
  # ───────────────────────────────────────────────────────────
  /apex/decisions:
    get:
      tags: [Apex]
      summary: List apex decisions with evidence counts
      parameters:
        - name: cycle_id
          in: query
          schema: { type: string, format: uuid }
        - name: domain
          in: query
          description: Apex decision domain (e.g. `risk_appetite`, `capital_and_balance_sheet`).
          schema: { type: string }
        - name: needs_ratification
          in: query
          schema: { type: string, enum: ["true", "false"] }
      responses:
        "200":
          description: Apex decisions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/Decision'
                        - type: object
                          properties:
                            evidence_count: { type: integer }
                            distinct_evidence_workflows:
                              type: array
                              items: { type: string }
                  total: { type: integer }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /apex/decisions/{decision_id}/evidence:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    get:
      tags: [Apex]
      summary: List active evidence links for an apex decision
      responses:
        "200":
          description: Evidence links with operational decision snapshots.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/ApexEvidenceLink' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
    post:
      tags: [Apex]
      summary: Link operational decision as evidence
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operational_decision_id]
              properties:
                operational_decision_id: { type: string, format: uuid }
                evidence_role:
                  type: string
                  enum: [primary_evidence, supporting, risk_concentration, regulatory_alignment, outcome, counter]
                  default: primary_evidence
                rationale: { type: string }
      responses:
        "201":
          description: Link created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApexEvidenceLink' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /apex/evidence/{link_id}/supersede:
    parameters:
      - name: link_id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Apex]
      summary: Supersede evidence link (optionally with replacement)
      description: |
        Append-only — old link keeps its row with `superseded_at` set; new
        link (if provided) gets `superseded_by_link_id` pointing back.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                replacement_operational_decision_id: { type: string, format: uuid }
                replacement_evidence_role: { type: string }
                rationale: { type: string }
      responses:
        "200":
          description: Superseded (with optional replacement).
          content:
            application/json:
              schema:
                type: object
                properties:
                  superseded: { $ref: '#/components/schemas/ApexEvidenceLink' }
                  replacement:
                    allOf:
                      - $ref: '#/components/schemas/ApexEvidenceLink'
                    nullable: true
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /apex/decisions/{decision_id}/board-action:
    parameters:
      - $ref: '#/components/parameters/DecisionId'
    post:
      tags: [Apex]
      summary: Record a board action
      description: |
        Records the outcome of a board cycle for this apex decision.
        Requires `apex_can_record_board_action=true` on the caller's
        workflow authority row. Transitions the decision to `closed`
        (for ratified actions) or `deferred` (for cycle-deferral).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [noted_by_board, ratified_by_board, ratified_by_committee, deferred_to_next_cycle]
                committee:
                  type: string
                  description: Committee name (required for `ratified_by_committee`).
                note: { type: string }
      responses:
        "200":
          description: Recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  action: { type: string }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /apex/cycles:
    get:
      tags: [Apex]
      summary: List board cycles
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [planned, active, closed]
      responses:
        "200":
          description: Board cycles with per-cycle decision counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/BoardCycle' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Apex]
      summary: Create a board cycle
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, cycle_type, cycle_start, cycle_end]
              properties:
                name: { type: string }
                cycle_type: { type: string }
                cycle_start: { type: string, format: date }
                cycle_end: { type: string, format: date }
                committee: { type: string }
                meeting_date: { type: string, format: date }
                notes: { type: string }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoardCycle' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }

  /apex/cycles/{cycle_id}/dossier:
    parameters:
      - $ref: '#/components/parameters/CycleId'
    get:
      tags: [Apex]
      summary: Download cycle dossier (PDF)
      description: |
        Board-ready PDF. Concluded cycles return the published dossier;
        active cycles return a draft (rendered fresh from current cycle
        state).
      responses:
        "200":
          description: PDF download.
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────────────────────────────────────
  # DGI
  # ───────────────────────────────────────────────────────────
  /dgi/current:
    get:
      tags: [DGI]
      summary: Most recent DGI measurement
      description: |
        Returns the latest measurement row for the caller's tenant.
        404 if DGI hasn't been computed yet (new tenant, sandbox tenant
        with score suppression, or the nightly task hasn't fired yet).
      responses:
        "200":
          description: Current measurement.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DgiMeasurement' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }

  /dgi/history:
    get:
      tags: [DGI]
      summary: Historical DGI trend (oldest → newest)
      parameters:
        - name: quarters
          in: query
          schema: { type: integer, default: 8, minimum: 1, maximum: 40 }
      responses:
        "200":
          description: Historical measurements.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/DgiMeasurement' }
        "401": { $ref: '#/components/responses/Unauthorized' }

  /dgi/components/{component_id}/contributing-decisions:
    parameters:
      - name: component_id
        in: path
        required: true
        description: Component identifier (e.g. `ownership_clarity`, `cycle_time_discipline`, `documented_rationale`, `outcome_capture`, `authority_appropriateness`, `apex_linkage`).
        schema: { type: string }
    get:
      tags: [DGI]
      summary: Decisions contributing to a DGI component
      description: |
        Drills into a single DGI component to surface which decisions
        passed or failed the component's check during the current
        measurement period.
      responses:
        "200":
          description: Contributing decisions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  component_id: { type: string }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        decision_number: { type: string }
                        title: { type: string }
                        workflow_id: { type: string }
                        priority: { type: string }
                        status: { type: string }
                        created_at: { type: string, format: date-time }
                        decided_at: { type: string, format: date-time, nullable: true }
                        passed: { type: boolean }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────────────────────────────────────
  # SETTINGS — tenant
  # ───────────────────────────────────────────────────────────
  /settings/tenant:
    get:
      tags: [Settings]
      summary: Read tenant context
      description: |
        Drives the Institution pane at `/settings/tenant`. Combines basic
        tenant fields, governance flags, and the unstructured
        `tenant_profile` JSONB block (peer group, fiscal year start
        month, board cadence list, examiner contacts list, risk
        appetite statement).
      responses:
        "200":
          description: Full tenant context.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Settings]
      summary: Partial update of tenant configuration
      description: |
        Admin-only. `has_apex` and `apex_started_at` are intentionally
        not editable here — entitlement lifecycle owns those fields.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                org_type:
                  type: string
                  enum: [CREDIT_UNION, COMMUNITY_BANK, SAVINGS_INSTITUTION]
                regulator:
                  type: string
                  enum: [NCUA, FDIC, OCC, STATE]
                asset_size_band:
                  type: string
                  enum: [small, medium, large, very_large]
                dgi_publication_opt_out: { type: boolean }
                tenant_profile:
                  type: object
                  description: Partial merge into `tenant_profile` JSONB.
                  properties:
                    peer_group: { type: string }
                    fiscal_year_start_month: { type: integer, minimum: 1, maximum: 12 }
                    board_cadence:
                      type: array
                      items: { type: object, additionalProperties: true }
                    examiner_contacts:
                      type: array
                      items: { type: object, additionalProperties: true }
                    risk_appetite_statement: { type: string }
                  additionalProperties: true
                governance:
                  type: object
                  properties:
                    dual_control_enabled: { type: boolean }
                    require_approval_roles:
                      type: array
                      items: { type: string }
      responses:
        "200":
          description: Updated tenant.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tenant' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────────────────────────────────────
  # SETTINGS — users
  # ───────────────────────────────────────────────────────────
  /settings/users:
    get:
      tags: [Settings]
      summary: List users in caller's tenant (admin only)
      responses:
        "200":
          description: User list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/User' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Settings]
      summary: Invite a new user
      description: |
        Creates a user in `pending_invite` status with a temporary password.
        The temp password is returned in the response body so the inviting
        admin can hand it off out-of-band; first login forces a password change.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, email, name]
              properties:
                username: { type: string }
                email: { type: string, format: email }
                name: { type: string }
                role:
                  type: string
                  enum: [admin, member]
                  default: member
                title: { type: string }
      responses:
        "201":
          description: Invited.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/User'
                  - type: object
                    properties:
                      temp_password: { type: string }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "409":
          description: Username or email already in use.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /settings/users/{user_id}:
    parameters:
      - $ref: '#/components/parameters/UserId'
    get:
      tags: [Settings]
      summary: Fetch a single user (admin only)
      responses:
        "200":
          description: User row.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
    put:
      tags: [Settings]
      summary: Update user fields (admin only)
      description: Username and email are immutable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                title: { type: string }
                role:
                  type: string
                  enum: [admin, member]
                status:
                  type: string
                  enum: [active, inactive, locked, pending_invite]
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Settings]
      summary: Deactivate user (admin only)
      description: Soft-delete — sets `status='inactive'`. Row retained for audit.
      responses:
        "200":
          description: Deactivated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/User' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────────────────────────────────────
  # SETTINGS — entitlements (per-workflow grants)
  # ───────────────────────────────────────────────────────────
  /settings/entitlements:
    get:
      tags: [Settings]
      summary: List per-workflow entitlements (admin only)
      description: |
        Returns three lists:
          - `active` — currently-active grants (`ended_at IS NULL`)
          - `history` — every row for the tenant, newest first
          - `available` — every workflow loaded by the semantic engine
            (the universe of grantable workflows)
      responses:
        "200":
          description: Entitlement state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  active:
                    type: array
                    items: { $ref: '#/components/schemas/TenantEntitlement' }
                  history:
                    type: array
                    items: { $ref: '#/components/schemas/TenantEntitlement' }
                  available:
                    type: array
                    items: { $ref: '#/components/schemas/Workflow' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Settings]
      summary: Grant a workflow entitlement (admin only)
      description: Idempotent for already-active grants.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workflow_id]
              properties:
                workflow_id: { type: string }
                status:
                  type: string
                  enum: [trial, active, suspended]
                  default: active
                pricing_tier: { type: string }
                notes: { type: string }
      responses:
        "201":
          description: Granted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantEntitlement' }
        "200":
          description: Already active (no change made).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantEntitlement' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }

  /settings/entitlements/{workflow_id}:
    parameters:
      - $ref: '#/components/parameters/WorkflowId'
    delete:
      tags: [Settings]
      summary: Revoke a workflow entitlement (admin only)
      description: |
        Append-only — sets `ended_at` and `status='expired'` rather than
        deleting the row.
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantEntitlement' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────────────────────────────────────
  # SETTINGS — semantic-layer overrides
  # ───────────────────────────────────────────────────────────
  /settings/workflows/{workflow_id}/config:
    parameters:
      - $ref: '#/components/parameters/WorkflowId'
    get:
      tags: [Settings]
      summary: Merged config + override allowlist (admin only)
      responses:
        "200":
          description: Config + allowed override keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  config: { $ref: '#/components/schemas/WorkflowConfig' }
                  overridable_keys:
                    type: array
                    items: { type: string }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /settings/workflows/{workflow_id}/overrides:
    parameters:
      - $ref: '#/components/parameters/WorkflowId'
    post:
      tags: [Settings]
      summary: Set / update an override (admin only)
      description: |
        Append-only — supersedes any existing override on the same
        `config_key` by writing a new row. Validation rejects keys not
        in the workflow's `overridable_keys` allowlist and values that
        don't match the expected type.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [config_key, config_value]
              properties:
                config_key: { type: string, example: "thresholds.sar_timeliness_target" }
                config_value:
                  description: Override value (any JSON-serializable type).
                change_note: { type: string }
      responses:
        "201":
          description: Override written.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SemanticOverride' }
        "400": { $ref: '#/components/responses/BadRequest' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /settings/workflows/{workflow_id}/overrides/{config_key}:
    parameters:
      - $ref: '#/components/parameters/WorkflowId'
      - name: config_key
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: [Settings]
      summary: Remove an override (admin only)
      description: |
        Append-only soft-delete — sets `superseded_at` on the active
        row. Old row retained for audit.
      responses:
        "200":
          description: Superseded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "superseded" }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  /settings/workflows/{workflow_id}/overrides/history:
    parameters:
      - $ref: '#/components/parameters/WorkflowId'
    get:
      tags: [Settings]
      summary: Full override audit history (admin only)
      responses:
        "200":
          description: Every override row, including superseded ones.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/SemanticOverride' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "403": { $ref: '#/components/responses/Forbidden' }
        "404": { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────────────────────────────────────
  # SYSTEM
  # ───────────────────────────────────────────────────────────
  /health:
    get:
      tags: [System]
      summary: Health check
      security: []
      responses:
        "200":
          description: Service is healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "healthy" }
                  version: { type: string }
