# docs/openapi.yaml
# GENERATED by cmd/openapi-bundle — edit fragments under docs/openapi/, then re-run.
# OpenAPI specification for the RMM Platform API, served at /api/docs.
# Source of truth: docs/openapi/root.yaml + docs/openapi/paths/*.yaml.

# NOTE: kept at 3.0.3 for now; planned bump to OpenAPI 3.1 in Wave 3
# (needed for top-level `webhooks`, per SPEC 72 D-05).
openapi: "3.0.3"
info:
  title: RMM API
  description: |
    Remote Monitoring and Management platform API.

    ## Stability Tiers
    Every operation carries an `x-stability` tier:
    - **stable** — the public API proper. Breaking changes only with at least
      6 months' notice via `Deprecation`/`Sunset` headers and the changelog.
    - **beta** — recently shipped and still moving. Changes are announced in
      the changelog; no sunset window.
    - **internal** — exists for the web UI. Documented for transparency,
      zero guarantees, may change or vanish in any release.

    ## Authentication
    All authenticated endpoints require a Bearer token obtained via `POST /login`.
    Tokens are opaque random strings (stored server-side as SHA-256 hashes). Include the token in the `Authorization` header:
    ```
    Authorization: Bearer <token>
    ```

    ## Multi-Tenancy
    All responses are tenant-scoped via PostgreSQL row-level security. Users only see data
    belonging to their organization.

    ## Rate Limiting
    Endpoints are rate-limited per IP address. Response headers include:
    - `X-RateLimit-Limit` — Maximum requests per window
    - `X-RateLimit-Remaining` — Remaining requests in the current window
    - `X-RateLimit-Reset` — Unix timestamp when the window resets
    - `Retry-After` — Seconds to wait (on 429 responses)

    | Endpoint Type | Limit |
    |---|---|
    | Login | 5/minute |
    | General API | 100/minute |
    | Heavy operations | 10/minute |

    ## Token Rotation
    When a token reaches 50% of its TTL, the server returns a new token in the
    `X-New-Token` response header. Clients should update their stored token.

    ## Pagination
    List endpoints return paginated results. Use `page` and `per_page` query parameters.
    Responses include a `pagination` object with `page`, `per_page`, `total`, and `total_pages`.

    ## Full Interactive Documentation
    When the server is running, visit `/api/docs` for the interactive API reference with try-it-out capabilities.

    ## Changelog
    API changes are announced at [help.opsmerge.cloud/admin/api-changelog](https://help.opsmerge.cloud/admin/api-changelog).
  version: "2026-06-11"
  contact:
    name: OpsMerge Support
    url: https://help.opsmerge.cloud
servers:
  - url: /api/v1
    description: API v1
security:
  - BearerAuth: []
tags:
  - name: Auth
    description: Authentication and session management
  - name: MFA
    description: Multi-factor authentication (TOTP)
  - name: SSO
    description: OpenID Connect Single Sign-On
  - name: Users
    description: User account management
  - name: Roles
    description: Role and permission management
  - name: Organizations
    description: Multi-tenant organization management (superuser only)
  - name: Clients
    description: Client management within an organization
  - name: Sites
    description: Site management within clients
  - name: Agents
    description: Agent lifecycle, status, commands, and bulk operations
  - name: Checks
    description: Health check management and results
  - name: Tasks
    description: Automated task scheduling and results
  - name: Scripts
    description: Script library, execution, and streaming
  - name: Policies
    description: Policy engine — check/task templates with hierarchy cascade
  - name: Alerts
    description: Alert management and alert templates
  - name: Notifications
    description: SMTP, webhook, and notification settings
  - name: Reports
    description: Report templates, data queries, generation, and scheduling
  - name: Files
    description: Remote file browser (browse, download, upload)
  - name: Software
    description: Software inventory
  - name: Registry
    description: Windows registry editor
  - name: RustDesk
    description: RustDesk remote access integration
  - name: CodeSigning
    description: Agent binary signing and Authenticode certificates
  - name: Distribution
    description: Agent version management, updates, and installer generation
  - name: Security
    description: Security settings, lockout management
  - name: CustomFields
    description: Custom field definitions and values
  - name: Notes
    description: Agent notes
  - name: Audit
    description: Audit log
  - name: Health
    description: Liveness and readiness probes
  - name: Tickets
    description: PSA tickets, comments, attachments, watchers and triage
  - name: Ticket Templates
    description: Reusable ticket templates
  - name: Canned Responses
    description: Reusable reply snippets for ticket comments
  - name: Saved Filters
    description: Per-user saved list filters
  - name: Contacts
    description: Client contacts and portal linking
  - name: Billing Services
    description: PSA service catalogue and categories
  - name: Contracts
    description: PSA contracts, lines, billing cycles and asset category mapping
  - name: Invoices
    description: PSA invoices, items, issue/void lifecycle and PDFs
  - name: Billing Settings
    description: Tenant billing configuration, VAT, branding and lookups
  - name: Time Tracking
    description: Time entries, timers and timesheet workflow
  - name: Knowledge Base
    description: Internal KB articles, versions and categories
  - name: Documents
    description: Document storage, uploads and associations
  - name: Domain Monitoring
    description: SSL/DNS/WHOIS/HTTP domain monitors
  - name: Agent Enrolment
    description: Registration token lifecycle and installer downloads
  - name: Quotes
    description: PSA quotes, sections, items and lifecycle
  - name: Quote Templates
    description: Reusable quote templates and instantiation
  - name: Quote Approval
    description: Quote approval chains and decisions
  - name: Projects
    description: PSA projects, phases, milestones, risks and approvals
  - name: Project Templates
    description: Reusable project templates and versions
  - name: Products
    description: Product catalogue and supplier SKU links
  - name: Catalogue
    description: Cross-supplier catalogue search
  - name: Suppliers
    description: Suppliers and price-feed ingestion
  - name: Procurement
    description: Procured item tracking
  - name: MDM
    description: Mobile device management (beta, per-org flag)
  - name: CSAT
    description: Customer satisfaction surveys and analytics
  - name: Asset Groups
    description: Smart asset groups with filter DSL
  - name: Network Devices
    description: SNMP network device inventory and polling
  - name: Email Gateway
    description: Inbound/outbound email domains, mailboxes, blocklist and activity
  - name: PSA Settings
    description: Ticket categories, priority matrix, SLA plans and AI feature config
  - name: Ticket Rules
    description: Ticket automation rules and dry-run
  - name: QuickBooks
    description: QuickBooks Online accounting sync
  - name: FreeAgent
    description: FreeAgent accounting sync
  - name: Entra
    description: Microsoft Entra contact sync
  - name: Pax8
    description: Pax8 marketplace sync
  - name: CIPP
    description: CIPP tenant mapping and sync
  - name: Distributors
    description: Ingram, TD Synnex and Exertis supplier integrations
  - name: Bitdefender
    description: Bitdefender GravityZone integration (bring-your-own-tenant)
  - name: Telecom
    description: Dial 9 telecom billing (EU cell only)
  - name: Client Portal
    description: End-customer portal (session-bound, not an integration surface)
  - name: Portal Branding
    description: Tenant portal branding administration
  - name: Tray
    description: System tray app configuration and self-service script runs
  - name: Virtualization
    description: Hypervisor hosts and VM actions
  - name: Licenses
    description: Software licence tracking and assignment
  - name: Backups
    description: Backup endpoint mapping and plans
  - name: Endpoint Security
    description: Per-agent endpoint security state
  - name: Patches
    description: Patch policies, scans and managed updates
  - name: Agent Operations
    description: Live agent operations - processes, services, registry, power
  - name: Terminal
    description: Remote terminal WebSocket
  - name: Remote Desktop
    description: Remote desktop and OpsMerge Connect configuration
  - name: API Keys
    description: API key management and scope catalogue (session-only)
  - name: AI
    description: AI-assisted ticket features
  - name: Chat
    description: Technician-to-user chat sessions
  - name: Account
    description: Own profile, preferences and auth tickets
  - name: Platform Billing
    description: OpsMerge subscription management (denied to API keys)
  - name: Webhooks (Inbound)
    description: Third-party webhook receivers with per-provider authentication
  - name: ThreatDown
    description: ThreatDown/Malwarebytes OneView integration
  - name: Triggers
    description: Event trigger configuration
  - name: Compliance
    description: Compliance scores and retention policies
  - name: Anomaly Detection
    description: Anomaly detection findings and config
  - name: Platform Admin
    description: Platform-operator endpoints (not tenant-facing)
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: Auth token obtained from POST /login, or an API key (om_ prefix) from Settings -> API Keys
  parameters:
    PageParam:
      name: page
      in: query
      description: Page number (1-based)
      schema:
        type: integer
        minimum: 1
        default: 1
    PerPageParam:
      name: per_page
      in: query
      description: Items per page (max 100)
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
  responses:
    Unauthorized:
      description: Missing or invalid bearer token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: Authenticated but not permitted (role, scope, user type or tier). API keys receive SCOPE_DENIED or API_KEY_FORBIDDEN codes.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: Resource does not exist in this tenant
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    ValidationError:
      description: Request body failed validation
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    RateLimited:
      description: Rate limit exceeded; see Retry-After and X-RateLimit-* headers
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          properties:
            message:
              type: string
              example: "invalid user ID"
            code:
              type: string
              example: "INVALID_ID"
            details:
              type: object
              description: Optional structured detail (e.g. required_scope on SCOPE_DENIED)
              nullable: true
    PaginationMeta:
      type: object
      properties:
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 50
        total:
          type: integer
          example: 120
        total_pages:
          type: integer
          example: 3
    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        username:
          type: string
          example: "admin"
        email:
          type: string
          format: email
        role_id:
          type: string
          format: uuid
          nullable: true
        is_superuser:
          type: boolean
        is_active:
          type: boolean
        mfa_enabled:
          type: boolean
        last_login:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateUserRequest:
      type: object
      required: [username, email, password]
      properties:
        username:
          type: string
          minLength: 3
          maxLength: 150
        email:
          type: string
          format: email
          maxLength: 255
        password:
          type: string
          minLength: 8
        role_id:
          type: string
          format: uuid
          nullable: true
        is_superuser:
          type: boolean
          default: false
    UpdateUserRequest:
      type: object
      properties:
        username:
          type: string
          minLength: 3
          maxLength: 150
        email:
          type: string
          format: email
        role_id:
          type: string
          format: uuid
        is_superuser:
          type: boolean
        is_active:
          type: boolean
    ChangePasswordRequest:
      type: object
      required: [current_password, new_password]
      properties:
        current_password:
          type: string
        new_password:
          type: string
          minLength: 8
    LoginRequest:
      type: object
      required: [username, password]
      properties:
        username:
          type: string
        password:
          type: string
    LoginResponse:
      type: object
      properties:
        token:
          type: string
        user:
          $ref: "#/components/schemas/User"
    MFALoginRequest:
      type: object
      required: [mfa_token, code]
      properties:
        mfa_token:
          type: string
          description: Temporary MFA token from initial login
        code:
          type: string
          description: 6-digit TOTP code or backup code
    Agent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        site_id:
          type: string
          format: uuid
        hostname:
          type: string
        os_platform:
          type: string
          example: "windows"
        os_version:
          type: string
        agent_version:
          type: string
        public_ip:
          type: string
        status:
          type: string
          enum: [online, offline, overdue]
        last_seen:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
    Check:
      type: object
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        check_type:
          type: string
          enum: [cpu, memory, disk, service, ping, http, script]
        name:
          type: string
        enabled:
          type: boolean
        interval_seconds:
          type: integer
        config:
          type: object
          description: Type-specific configuration
        warning_threshold:
          type: number
        critical_threshold:
          type: number
        last_status:
          type: string
          enum: [passing, warning, critical, error, pending]
        created_at:
          type: string
          format: date-time
    Task:
      type: object
      properties:
        id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        name:
          type: string
        schedule_type:
          type: string
          enum: [manual, daily, weekly, monthly]
        schedule_config:
          type: object
        actions:
          type: array
          items:
            type: object
        continue_on_error:
          type: boolean
        enabled:
          type: boolean
        last_run:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    Script:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        category_id:
          type: string
          format: uuid
          nullable: true
        shell:
          type: string
          enum: [powershell, cmd, bash, python]
        body:
          type: string
        default_timeout:
          type: integer
        created_at:
          type: string
          format: date-time
    Policy:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
    Alert:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        agent_id:
          type: string
          format: uuid
        alert_type:
          type: string
          enum: [check, task, availability]
        severity:
          type: string
          enum: [info, warning, error, critical]
        message:
          type: string
        status:
          type: string
          enum: [open, acknowledged, resolved, snoozed]
        acknowledged_by:
          type: string
          format: uuid
          nullable: true
        resolved_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    AuditLog:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
          nullable: true
        action:
          type: string
          example: "create"
        resource_type:
          type: string
          example: "user"
        resource_id:
          type: string
          format: uuid
          nullable: true
        ip_address:
          type: string
          nullable: true
        user_agent:
          type: string
          nullable: true
        changes:
          type: object
          nullable: true
        created_at:
          type: string
          format: date-time
    ReportTemplate:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        body:
          type: string
          description: Go template with Markdown
        created_at:
          type: string
          format: date-time
    SSOProvider:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        issuer_url:
          type: string
        client_id:
          type: string
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
paths:
  /admin/boe-base-rates:
    get:
      x-stability: internal
      summary: List Bank of England base rates
      description: |
        Global table used for statutory-interest calculations on overdue PSA
        invoices. Platform admin only (RequirePlatformAdmin — role gate, no
        API-key scope).
      operationId: listBoeBaseRates
      tags: [Platform Admin]
      responses:
        "200":
          description: All base-rate rows, newest effective_from first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &BoeBaseRate
                          type: object
                          properties:
                            effective_from:
                              type: string
                              description: YYYY-MM-DD
                            rate_bps:
                              type: integer
                              description: Rate in basis points
                            source_url: {type: string}
                            notes: {type: string}
                            created_at: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      summary: Upsert a Bank of England base rate
      description: Inserts or updates the row keyed by effective_from. Platform admin only.
      operationId: upsertBoeBaseRate
      tags: [Platform Admin]
      requestBody:
        required: true
        content:
          application/json:
            schema: *BoeBaseRate
      responses:
        "200":
          description: Rate stored
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      summary: Delete a Bank of England base rate
      description: Deletes the row for ?effective_from=YYYY-MM-DD. Platform admin only.
      operationId: deleteBoeBaseRate
      tags: [Platform Admin]
      parameters:
        - name: effective_from
          in: query
          required: true
          schema: {type: string}
          description: YYYY-MM-DD of the row to delete
      responses:
        "204":
          description: Rate deleted
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agent-versions:
    get:
      x-stability: internal
      summary: List uploaded agent versions
      description: |
        Paginated agent_versions list for the tenant, newest first.
        Platform admin only.
      operationId: listAgentVersions
      tags: [Distribution]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: platform
          in: query
          schema:
            type: string
            enum: [windows, linux, darwin]
        - name: arch
          in: query
          schema:
            type: string
            enum: [amd64, arm64, "386"]
        - name: binary_type
          in: query
          schema:
            type: string
            enum: [agent, tray, updater, connect, installer-msi, installer-stub, console, console-msi]
      responses:
        "200":
          description: Version list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            version: {type: string}
                            platform: {type: string}
                            arch: {type: string}
                            binary_type: {type: string}
                            checksum_sha256: {type: string}
                            size: {type: integer, format: int64}
                            is_current: {type: boolean}
                            created_at: {type: string, format: date-time}
                      pagination:
                        $ref: '#/components/schemas/PaginationMeta'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agent-versions/current:
    get:
      x-stability: internal
      summary: Get current agent version metadata (public)
      description: |
        Unauthenticated version-check endpoint used by agents for
        self-update polling. An optional `Authorization: Bearer
        <registration token>` scopes the lookup to that org's private
        binary; an unrecognised Bearer silently falls back to the global
        build (it is a scope hint, not an access gate). Returns 404 when no
        current version matches, or when agent updates are globally paused
        (`PAUSE_AGENT_UPDATES`, code `UPDATES_PAUSED`).
      operationId: getCurrentAgentVersion
      tags: [Agent Operations]
      security: []
      parameters:
        - name: platform
          in: query
          required: true
          description: Target platform (e.g. windows, linux, darwin)
          schema: {type: string}
        - name: arch
          in: query
          required: true
          description: Target architecture (e.g. amd64, arm64)
          schema: {type: string}
        - name: binary_type
          in: query
          description: Binary flavour (defaults to agent)
          schema: {type: string, default: agent}
      responses:
        "200":
          description: Current version metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      version: {type: string}
                      platform: {type: string}
                      arch: {type: string}
                      checksum_sha256: {type: string}
                      size: {type: integer, format: int64}
                      is_current: {type: boolean}
                      created_at: {type: string, format: date-time}
        "400": {$ref: '#/components/responses/ValidationError'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agent-versions/current/checksum:
    get:
      x-stability: internal
      summary: Get current agent binary checksum (public, text/plain)
      description: |
        Returns the SHA-256 hex digest of the current agent binary as
        `text/plain` so install scripts can verify a download without JSON
        parsing. Optional Bearer registration token scopes the lookup to a
        per-org binary; an invalid or expired Bearer returns 401 (matches
        the download endpoint's auth model).
      operationId: getCurrentAgentVersionChecksum
      tags: [Agent Operations]
      security: []
      parameters:
        - name: platform
          in: query
          required: true
          schema: {type: string}
        - name: arch
          in: query
          required: true
          schema: {type: string}
        - name: binary_type
          in: query
          schema: {type: string, default: agent}
      responses:
        "200":
          description: SHA-256 hex digest of the current binary
          content:
            text/plain:
              schema: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agent-versions/current/download:
    get:
      x-stability: internal
      summary: Download the current agent binary (public)
      description: |
        Streams the current agent binary for a platform/arch. Optional
        registration token (Bearer header preferred; `token` query param is
        legacy) scopes the download to a per-org binary. Response carries
        `X-Signature-Ed25519` and `X-Checksum-SHA256` headers for
        verification.
      operationId: downloadCurrentAgentVersion
      tags: [Agent Operations]
      security: []
      parameters: &CurrentDownloadParams
        - name: platform
          in: query
          required: true
          schema: {type: string}
        - name: arch
          in: query
          required: true
          schema: {type: string}
        - name: binary_type
          in: query
          schema: {type: string, default: agent}
        - name: token
          in: query
          description: Legacy registration-token transport; prefer the Bearer header
          schema: {type: string}
      responses: &CurrentDownloadResponses
        "200":
          description: Binary stream (attachment) with signature and checksum headers
          headers:
            X-Signature-Ed25519:
              description: Hex-encoded Ed25519 signature of the binary
              schema: {type: string}
            X-Checksum-SHA256:
              description: SHA-256 hex digest of the binary
              schema: {type: string}
          content:
            application/octet-stream:
              schema: {type: string, format: binary}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    head:
      x-stability: internal
      summary: Probe the current agent binary download (public)
      description: HEAD variant of the current-binary download; same parameters and headers, no body.
      operationId: headCurrentAgentVersionDownload
      tags: [Agent Operations]
      security: []
      parameters: *CurrentDownloadParams
      responses: *CurrentDownloadResponses
  /agent-versions/upload:
    post:
      x-stability: internal
      summary: Upload a signed agent binary
      description: |
        Multipart upload (max 200 MB). The server Ed25519-signs the
        binary with the org signing key (400 `SIGNING_KEY_MISSING` if
        none exists), Authenticode-signs Windows binaries when a
        platform cert is configured, computes the SHA-256, marks the new
        row current, and unsets the previous current row for the same
        platform/arch/binary_type. `scope=global` (superuser only)
        publishes the binary to all tenants. Platform admin only.
      operationId: uploadAgentVersion
      tags: [Distribution]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [binary, version, platform, arch]
              properties:
                binary:
                  type: string
                  format: binary
                version:
                  type: string
                  maxLength: 50
                platform:
                  type: string
                  enum: [windows, linux, darwin]
                arch:
                  type: string
                  enum: [amd64, arm64, "386"]
                binary_type:
                  type: string
                  enum: [agent, tray, updater, connect, installer-msi, installer-stub, console, console-msi]
                  default: agent
                scope:
                  type: string
                  enum: [global]
                  description: "global = visible to every tenant (superuser only); omit for per-org"
      responses:
        "201":
          description: Version uploaded and marked current
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      version: {type: string}
                      platform: {type: string}
                      arch: {type: string}
                      checksum_sha256: {type: string}
                      size: {type: integer, format: int64}
                      is_current: {type: boolean}
                      created_at: {type: string, format: date-time}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agent-versions/{id}:
    delete:
      x-stability: internal
      summary: Delete an agent version
      description: |
        Removes the version record and its binary from disk. The current
        version for a platform/arch cannot be deleted — upload a
        replacement first (409 `DELETE_CURRENT`). Platform admin only.
      operationId: deleteAgentVersion
      tags: [Distribution]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "204":
          description: Version deleted
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Cannot delete the current version (DELETE_CURRENT)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agent-versions/{id}/download:
    get:
      x-stability: internal
      summary: Download an agent binary (authenticated)
      description: |
        Streams the binary with `X-Signature-Ed25519` (hex) and
        `X-Checksum-SHA256` headers for client-side verification.
        `binary_type` only shapes the suggested download filename.
        Platform admin only; installers use the public-download
        endpoints instead (rmm-extended-b.yaml).
      operationId: downloadAgentVersion
      tags: [Distribution]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
        - name: binary_type
          in: query
          description: Display-filename hint only (defaults to agent)
          schema: {type: string, default: agent}
      responses:
        "200":
          description: Agent binary
          headers:
            X-Signature-Ed25519:
              description: Hex-encoded Ed25519 signature of the binary
              schema: {type: string}
            X-Checksum-SHA256:
              description: SHA-256 hex digest of the binary
              schema: {type: string}
          content:
            application/octet-stream:
              schema: {type: string, format: binary}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agent-versions/{id}/public-download:
    get:
      x-stability: internal
      summary: Download a specific agent version binary (public)
      description: |
        Streams a specific (non-current) agent version. A valid
        registration token (Bearer header or legacy `token` query param)
        is required to download org-scoped binaries; an invalid or expired
        token returns 401. Response carries `X-Signature-Ed25519` and
        `X-Checksum-SHA256` headers.
      operationId: publicDownloadAgentVersion
      tags: [Agent Operations]
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
        - name: binary_type
          in: query
          schema: {type: string, default: agent}
        - name: token
          in: query
          description: Legacy registration-token transport; prefer the Bearer header
          schema: {type: string}
      responses:
        "200":
          description: Binary stream (attachment) with signature and checksum headers
          headers:
            X-Signature-Ed25519:
              schema: {type: string}
            X-Checksum-SHA256:
              schema: {type: string}
          content:
            application/octet-stream:
              schema: {type: string, format: binary}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents:
    get:
      x-stability: stable
      x-required-scope: agents:read
      summary: List agents
      description: |
        Paginated, filterable, server-side-sorted agent list. Uses
        `page`/`per_page` paging and returns `{items, pagination}`.
        `status` is computed from `last_seen` against org thresholds and
        filtered post-query (when supplied, `pagination.total` reflects
        the filtered page, not the full fleet). `sort_by` is whitelisted
        server-side; unknown values silently fall back to the default
        display-name/hostname ordering. `sort_dir` accepts only `desc` —
        anything else sorts ascending. MDM-beta tenants get mobile
        devices unioned into the same row shape.
      operationId: listAgents
      tags: [Agents]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: status
          in: query
          description: Computed-status filter, applied after pagination
          schema:
            type: string
            enum: [online, disconnected, offline, unknown]
        - name: site_id
          in: query
          schema: {type: string, format: uuid}
        - name: client_id
          in: query
          schema: {type: string, format: uuid}
        - name: search
          in: query
          description: Case-insensitive match on hostname, display name, or agent_id
          schema: {type: string}
        - name: category
          in: query
          description: High-level asset bucket (maps to asset_type sets server-side)
          schema:
            type: string
            enum: [servers, workstations, network_devices, mobile_devices, printers]
        - name: sort_by
          in: query
          description: Whitelisted sort column; unknown values fall back to default ordering
          schema:
            type: string
            enum: [hostname, operating_system, logged_in_user, client_name, site_name, version, tray_version, last_seen, asset_type, created_at]
        - name: sort_dir
          in: query
          description: Only `desc` is recognised; any other value sorts ascending
          schema:
            type: string
            enum: [asc, desc]
      responses:
        "200":
          description: Paginated agent list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            agent_id:
                              type: string
                              description: NATS-routable agent identifier (empty for mobile devices)
                            hostname: {type: string, nullable: true}
                            display_name: {type: string, nullable: true}
                            description: {type: string, nullable: true}
                            operating_system: {type: string, nullable: true}
                            platform: {type: string, nullable: true}
                            version: {type: string, nullable: true}
                            tray_version: {type: string, nullable: true}
                            status:
                              type: string
                              enum: [online, disconnected, offline, unknown]
                              description: Computed from last_seen against org thresholds
                            last_seen: {type: string, format: date-time, nullable: true}
                            site_name: {type: string}
                            client_name: {type: string}
                            site_id: {type: string, format: uuid}
                            client_id: {type: string, format: uuid}
                            maintenance_mode: {type: boolean}
                            needs_reboot: {type: boolean}
                            is_online: {type: boolean}
                            asset_type: {type: string}
                            logged_in_user: {type: string, nullable: true}
                            created_at: {type: string, format: date-time}
                      pagination:
                        $ref: '#/components/schemas/PaginationMeta'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/bulk:
    post:
      x-stability: stable
      x-required-scope: agents:execute
      summary: Execute a bulk operation on multiple agents
      description: |
        Dispatches one action to up to 500 agents. `command`, `reboot`
        and `shutdown` are fire-and-forget NATS publishes; for `command`
        the shell/command/timeout ride in `payload`. `maintenance_on` /
        `maintenance_off` update rows directly. `uninstall` publishes
        the uninstall command, deletes the rows, blocklists the agent
        IDs for 30 days, and evicts NATS credentials. Out-of-tenant or
        unknown IDs are skipped silently (reflected in `dispatched` vs
        `total`). Heavy-rate-limited. Audit-logged per agent.
      operationId: bulkAgentOperation
      tags: [Agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_ids, action]
              properties:
                agent_ids:
                  type: array
                  maxItems: 500
                  items: {type: string, format: uuid}
                action:
                  type: string
                  enum: [command, reboot, shutdown, maintenance_on, maintenance_off, uninstall]
                payload:
                  type: object
                  description: "Action parameters; used by action=command"
                  properties:
                    shell: {type: string}
                    command: {type: string}
                    timeout:
                      type: integer
                      default: 60
                      description: Seconds per agent command
      responses:
        "200":
          description: Bulk dispatch results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total:
                        type: integer
                        description: Agent IDs submitted
                      dispatched:
                        type: integer
                        description: Agents the action was applied/sent to
                      errors:
                        type: array
                        items: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS unavailable (command/reboot/shutdown actions)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/bulk/rotate-nats-creds:
    post:
      x-stability: internal
      x-required-scope: agents:write
      summary: Rotate NATS credentials for every agent in the tenant
      description: |
        Regenerates the NATS password for all agents, rewrites users.conf
        once, reloads the NATS server, then dispatches a rotate command to
        each agent. No request body.
      operationId: bulkRotateNatsCreds
      tags: [Agents]
      responses:
        "202":
          description: Rotation accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: rotated}
                      total: {type: integer}
                      dispatched: {type: integer}
                      message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/installer:
    post:
      x-stability: internal
      summary: Generate a platform-specific installer script
      description: |
        Mints a fresh 24-hour registration token for the site and
        returns a JSON object (not raw text) with the wrapper `script`,
        a copy-paste `one_liner`, and — on Windows when an installer-msi
        build exists — an `msi` block with download URL and msiexec
        command line. Platform admin only.
      operationId: generateInstaller
      tags: [Distribution]
      parameters:
        - name: arch
          in: query
          description: Target architecture for the binary lookup
          schema: {type: string, default: amd64}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [site_id, platform]
              properties:
                site_id:
                  type: string
                  format: uuid
                  description: Site the generated registration token is bound to
                platform:
                  type: string
                  enum: [windows, linux, darwin]
      responses:
        "200":
          description: Installer script, one-liner, and optional MSI block
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      script:
                        type: string
                        description: Full wrapper script (PowerShell or bash)
                      one_liner:
                        type: string
                        description: Copy-paste install command with embedded token
                      msi:
                        type: object
                        description: "Present only for platform=windows when an installer-msi build exists"
                        properties:
                          download_url: {type: string}
                          version: {type: string}
                          command:
                            type: string
                            description: Ready-to-paste msiexec command line
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/move:
    post:
      x-stability: internal
      x-required-scope: agents:write
      summary: Move agents to another site (bulk)
      operationId: moveAgents
      tags: [Agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_ids, site_id]
              properties:
                agent_ids:
                  type: array
                  maxItems: 500
                  items: {type: string, format: uuid}
                site_id: {type: string, format: uuid}
      responses:
        "200":
          description: Agents moved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      moved: {type: integer}
                      site_id: {type: string}
                      site_name: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/register:
    post:
      x-stability: stable
      summary: Register a new agent (called by the agent binary)
      description: |
        Public (unauthenticated) endpoint: exchanges a registration token
        for NATS credentials and creates or re-homes the agent row.
        Rate-limited per IP. Decode failures return a bespoke 400
        envelope with an extra `error_detail` field. Token failures are
        401 (`TOKEN_EXPIRED` / `TOKEN_INVALID` / `TOKEN_EXHAUSTED`);
        an agent_id already owned by a different tenant is 409
        (`AGENT_CROSS_TENANT`); free-tier/override asset caps return 402
        (`FREE_TIER_CAP_EXCEEDED` with cap_name/current_count detail).
      operationId: registerAgent
      security: []
      tags: [Agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [registration_token, agent_id]
              properties:
                registration_token:
                  type: string
                  description: Raw site registration token
                agent_id:
                  type: string
                  format: uuid
                  description: Agent-generated stable identity (UUID)
                hostname: {type: string}
                os: {type: string}
                platform: {type: string}
                version: {type: string}
      responses:
        "200":
          description: Agent registered; NATS credentials issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      agent_id: {type: string}
                      nats_url: {type: string}
                      nats_user: {type: string}
                      nats_password: {type: string}
                      checkin_interval:
                        type: integer
                        description: Heartbeat interval in seconds
                      signing_public_key:
                        type: string
                        description: Hex Ed25519 key for binary signature verification (omitted if the org has no signing key)
                      nkey_seed:
                        type: string
                        description: Per-agent NATS NKey seed (preferred over user/password when present)
                      region:
                        type: string
                        description: "Cell region (eu/us/ap); omitted in dev deployments"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "402":
          description: Free-tier or per-org asset cap exceeded (FREE_TIER_CAP_EXCEEDED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "409":
          description: Device already registered with another tenant (AGENT_CROSS_TENANT)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /agents/update:
    post:
      x-stability: internal
      summary: Trigger a fleet-wide agent update (staged rollout)
      description: |
        Publishes fire-and-forget `checkupdate` commands to online
        agents. `percentage` < 100 randomly samples that fraction of the
        online fleet; values outside 1-100 are treated as 100. There is
        no per-agent targeting here — use POST
        /agents/{agentID}/trigger-update for a single agent. Platform
        admin only.
      operationId: triggerAgentUpdate
      tags: [Distribution]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version_id]
              properties:
                version_id:
                  type: string
                  format: uuid
                  description: Target agent_versions row (must exist)
                percentage:
                  type: integer
                  minimum: 1
                  maximum: 100
                  default: 100
                  description: Fraction of online agents to target
      responses:
        "200":
          description: Update dispatch summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total_agents:
                        type: integer
                        description: Online agents in the org
                      targeted:
                        type: integer
                        description: Agents selected by the rollout percentage
                      dispatched:
                        type: integer
                        description: Commands successfully published
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/update-history:
    get:
      x-stability: internal
      summary: Get agent update history
      description: |
        Paginated agent_update_history rows, newest first. Hostname is
        blank when the agent row has since been deleted. Platform admin
        only.
      operationId: agentUpdateHistory
      tags: [Distribution]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        "200":
          description: Update history page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            agent_id: {type: string, format: uuid}
                            hostname: {type: string}
                            from_version: {type: string}
                            to_version: {type: string}
                            status: {type: string}
                            error_message: {type: string}
                            created_at: {type: string, format: date-time}
                      pagination:
                        $ref: '#/components/schemas/PaginationMeta'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/update-status:
    get:
      x-stability: internal
      summary: Get agent version distribution
      description: |
        Version distribution across the org's fleet — one row per
        version string with count and percentage. Platform admin only.
      operationId: agentUpdateStatus
      tags: [Distribution]
      responses:
        "200":
          description: Version distribution
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            version: {type: string}
                            count: {type: integer}
                            percentage: {type: number}
                      total_agents: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{agentID}/bitlocker-keys:
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: Reveal stored BitLocker recovery keys for an agent
      description: |
        Decrypts and returns the BitLocker recovery keys stored for a
        Windows agent. Keys are deliberately excluded from the agent detail
        response; every call to this endpoint is audit-logged
        (`agent.bitlocker.reveal`). Returns an empty `volumes` array when
        no keys are stored.
      operationId: revealBitLockerKeys
      tags: [Agent Operations]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Decrypted recovery keys
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      volumes:
                        type: array
                        items:
                          type: object
                          properties:
                            mount_point: {type: string}
                            protection_status: {type: string}
                            recovery_key: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{agentID}/chat-invite:
    parameters:
      - name: agentID
        in: path
        required: true
        description: Agent row UUID (agents.id, not the NATS agent_id token)
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: psa-chat:write
      summary: Invite an end user to chat
      description: |
        Starts a tech-to-end-user chat: creates a `pending` chat session
        and publishes a `chat-invite` command to the agent so the tray
        app can prompt the logged-in user. The invite expires after 60
        seconds if not accepted. Returns 409 `CHAT_ACTIVE` if a live
        session already exists on the device. Per-user throttle of 30
        invites/hour. A NATS publish failure does not fail the request —
        the session still exists and the sweeper will time it out.
      operationId: inviteChatSession
      tags: [Chat]
      responses:
        "201":
          description: Pending chat session created and invite dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session_id: {type: string, format: uuid}
                      expires_at: {type: string, format: date-time}
                      status:
                        type: string
                        enum: [pending, active, declined, closed, timed_out, expired]
                        example: pending
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: A chat is already in progress on this device (CHAT_ACTIVE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/checks:
    parameters:
      - name: agentID
        in: path
        required: true
        description: Agent primary key (UUID), not the NATS agent_id
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: checks:read
      summary: List checks for an agent
      description: |
        Returns every check on the agent, ordered by name. Not paginated —
        the response is a plain array.
      operationId: listChecksByAgent
      tags: [Checks]
      responses:
        "200":
          description: Check list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &Check
                      type: object
                      description: One row from the checks table
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        agent_id: {type: string, format: uuid}
                        policy_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Set when the check was materialised from a policy template
                        check_type:
                          type: string
                          enum: [cpu, memory, disk, service, ping, http, script, eventlog]
                        name: {type: string, maxLength: 255}
                        config:
                          type: object
                          description: |
                            Type-specific JSONB configuration. Keys by check_type:
                            disk: {disk}; service: {service_name}; ping: {host};
                            http: {url, expected_status}; script: {script_body,
                            shell, timeout, threshold_type, args}; eventlog:
                            {log_name, event_type, time_range_hours}. cpu and
                            memory take no config ({}).
                        warn_threshold: {type: number, nullable: true}
                        err_threshold: {type: number, nullable: true}
                        check_interval:
                          type: integer
                          minimum: 30
                          description: Seconds between runs (default 300)
                        alert_severity:
                          type: string
                          enum: [info, warning, error]
                          description: Severity of the alert raised on failure (default error)
                        fails_before_alert:
                          type: integer
                          description: Consecutive failures required before an alert is raised (default 1)
                        enabled: {type: boolean}
                        last_status:
                          type: string
                          nullable: true
                          enum: [pass, warning, error]
                          description: Null until the check has run at least once
                        last_value: {type: number, nullable: true}
                        last_run: {type: string, format: date-time, nullable: true}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: checks:write
      summary: Create a check on an agent
      description: |
        Creates a check and pushes the agent's updated check list over
        NATS (best-effort, asynchronous). check_interval must be >= 30
        seconds when supplied.
      operationId: createCheck
      tags: [Checks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [check_type, name]
              properties:
                check_type:
                  type: string
                  enum: [cpu, memory, disk, service, ping, http, script, eventlog]
                name: {type: string, maxLength: 255}
                config:
                  type: object
                  description: Type-specific configuration (see the Check schema); defaults to {}
                warn_threshold: {type: number, nullable: true}
                err_threshold: {type: number, nullable: true}
                check_interval: {type: integer, minimum: 30, default: 300}
                alert_severity:
                  type: string
                  enum: [info, warning, error]
                  default: error
                fails_before_alert: {type: integer, default: 1}
                enabled: {type: boolean, default: true}
      responses:
        "201":
          description: Check created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Check
        "400":
          description: Validation failure (VALIDATION_ERROR / INVALID_BODY / INVALID_ID)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /agents/{agentID}/eventlog:
    get:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Query event logs on an agent (live)
      description: |
        Proxies an `eventlog` command to the agent over NATS request/reply
        (30s agent timeout) and returns the agent's raw JSON array of
        event-log entries — NOT wrapped in the `{data:...}` envelope.
        504 when the agent does not respond; 503 when NATS is unavailable.
      operationId: queryAgentEventLog
      tags: [Agent Operations]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
        - name: log
          in: query
          description: Log name (e.g. System, Application); validated against an allow-pattern
          schema: {type: string}
        - name: count
          in: query
          description: Max entries to return (capped at 1000)
          schema: {type: integer, maximum: 1000}
        - name: event_id
          in: query
          description: Filter to a specific event ID
          schema: {type: integer}
        - name: level
          in: query
          schema:
            type: string
            enum: [critical, error, warning, information, info, verbose, debug]
      responses:
        "200":
          description: Raw event-log entry array from the agent (no envelope)
          content:
            application/json:
              schema:
                type: array
                items: {type: object}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/files:
    parameters:
      - name: agentID
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Browse files on an agent
      description: |
        Lists directory contents on the agent via NATS request/reply.
        `path` defaults to `/` when omitted. Returns 504 if the agent
        does not respond and 503 when NATS is unavailable.
      operationId: browseFiles
      tags: [Files]
      parameters:
        - name: path
          in: query
          required: false
          schema:
            type: string
            default: /
          description: Directory path to list (defaults to /)
      responses:
        "200":
          description: Directory listing (agent-provided entry array)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: NATS not available
        "504":
          description: Agent did not respond
    delete:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Delete a file or directory on an agent
      description: |
        Removes the file or directory at `path` on the agent. Directories
        are deleted recursively. Audit-logged.
      operationId: deleteFile
      tags: [Files]
      parameters:
        - name: path
          in: query
          required: true
          schema:
            type: string
          description: File or directory path to delete
      responses:
        "200":
          description: Deletion result from the agent
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: NATS not available
        "504":
          description: Agent did not respond
  /agents/{agentID}/files/copy:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Copy a file on an agent
      description: |
        Copies `src` to `dst` on the agent. A 20MB cap is enforced
        agent-side so large copies fail fast. Audit-logged.
      operationId: copyFile
      tags: [Files]
      parameters:
        - name: agentID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [src, dst]
              properties:
                src:
                  type: string
                dst:
                  type: string
      responses:
        "200":
          description: Copy result from the agent
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: NATS not available
        "504":
          description: Agent did not respond
  /agents/{agentID}/files/download:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Download a file from an agent
      description: |
        Fetches the file at `path` from the agent and returns its
        base64-encoded content as JSON (not an octet-stream).
      operationId: downloadFile
      tags: [Files]
      parameters:
        - name: agentID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path:
                  type: string
      responses:
        "200":
          description: File metadata and base64-encoded content (agent-provided JSON)
          content:
            application/json:
              schema:
                type: object
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: NATS not available
        "504":
          description: Agent did not respond
  /agents/{agentID}/files/mkdir:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Create a directory on an agent
      description: Creates the directory at `path` on the agent. Audit-logged.
      operationId: mkdirFile
      tags: [Files]
      parameters:
        - name: agentID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path:
                  type: string
      responses:
        "200":
          description: Mkdir result from the agent
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: NATS not available
        "504":
          description: Agent did not respond
  /agents/{agentID}/files/move:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Move a file on an agent
      description: |
        Moves `src` to `dst` on the agent — atomic rename when both paths
        are on the same filesystem, copy+delete fallback otherwise (same
        20MB cap as copy). Audit-logged.
      operationId: moveFile
      tags: [Files]
      parameters:
        - name: agentID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [src, dst]
              properties:
                src:
                  type: string
                dst:
                  type: string
      responses:
        "200":
          description: Move result from the agent
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: NATS not available
        "504":
          description: Agent did not respond
  /agents/{agentID}/files/upload:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Upload a file to an agent
      description: |
        Writes a file to `path` on the agent. The body is JSON (not
        multipart): `content` is the base64-encoded file payload, and
        `create_dirs` creates missing parent directories. Audit-logged
        (path and size only — content is elided from the audit row).
      operationId: uploadFile
      tags: [Files]
      parameters:
        - name: agentID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, content]
              properties:
                path:
                  type: string
                  description: Destination path on the agent
                content:
                  type: string
                  description: Base64-encoded file content
                create_dirs:
                  type: boolean
                  description: Create missing parent directories
      responses:
        "200":
          description: Upload result from the agent
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
        "503":
          description: NATS not available
        "504":
          description: Agent did not respond
  /agents/{agentID}/forecasts:
    parameters:
      - name: agentID
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: Get capacity forecasts for an agent
      description: |
        Linear-regression forecasts (disk fill etc.) computed server-side
        per check. By default only meaningful-confidence rows are
        returned; include_low=true adds low-confidence ones.
      operationId: getAgentForecasts
      tags: [Agents]
      parameters:
        - name: include_low
          in: query
          schema: {type: boolean}
      responses:
        "200":
          description: Forecast list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      agent_id: {type: string, format: uuid}
                      forecasts:
                        type: array
                        items:
                          type: object
                          properties:
                            check_id: {type: string, format: uuid}
                            check_name: {type: string}
                            check_type: {type: string}
                            today_value: {type: number}
                            threshold: {type: number}
                            slope_per_day: {type: number}
                            r_squared: {type: number}
                            confidence: {type: string}
                            crossing_at: {type: string, format: date-time, nullable: true}
                            days_to_cross: {type: integer, nullable: true}
                            window_days: {type: integer}
                            sample_count: {type: integer}
                            settling: {type: boolean}
                            computed_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{agentID}/history:
    parameters:
      - name: agentID
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: Get unified activity history for an agent
      description: |
        Merged feed of audit, check, script and update events for one
        agent, newest first. Paginated with page/per_page.
      operationId: getAgentHistory
      tags: [Agents]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: from
          in: query
          schema: {type: string, format: date-time}
        - name: to
          in: query
          schema: {type: string, format: date-time}
        - name: event_type
          in: query
          schema: {type: string}
      responses:
        "200":
          description: Paginated history feed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            event_type: {type: string}
                            timestamp: {type: string, format: date-time}
                            title: {type: string}
                            description: {type: string}
                            status: {type: string, nullable: true}
                            actor: {type: string, nullable: true}
                            details:
                              type: object
                              nullable: true
                      pagination: {$ref: '#/components/schemas/PaginationMeta'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{agentID}/log-level:
    parameters:
      - name: agentID
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Set the agent's log level (optionally time-boxed)
      description: |
        duration_seconds 0 = permanent until changed; capped at 24h.
        Level must be one of debug/info/warn/error.
      operationId: setAgentLogLevel
      tags: [Agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [level]
              properties:
                level:
                  type: string
                  enum: [debug, info, warn, error]
                duration_seconds:
                  type: integer
                  minimum: 0
                  maximum: 86400
      responses:
        "200":
          description: Log level command result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{agentID}/managed-updates:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get managed-updates state for an agent
      description: |
        Returns the per-agent managed-updates picture - the direct
        (nullable) flag, the effective value resolved through the
        agent > site > client > organisation hierarchy, plus the
        on-endpoint applied state, when it was applied, and the last
        push error if any.
      operationId: getAgentManagedUpdates
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Direct + effective + applied managed-updates view
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      direct:
                        type: boolean
                        nullable: true
                        description: Agent-level override; null = inherit
                      effective: {type: boolean}
                      source:
                        type: string
                        enum: [organization, client, site, agent]
                      state:
                        type: boolean
                        nullable: true
                        description: Last state the agent reported applying; null = not yet applied
                      applied_at: {type: string, format: date-time, nullable: true}
                      error: {type: string, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Set the agent-level managed-updates override
      description: |
        Sets `agents.managed_updates` (nullable - send `"managed": null`
        to clear the override and inherit) and immediately pushes the
        resolved set-update-control command to the agent.
      operationId: updateAgentManagedUpdates
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema: &ManagedUpdatesUpdateRequest
              type: object
              properties:
                managed:
                  type: boolean
                  nullable: true
                  description: Desired managed flag; null clears the override at this scope
      responses:
        "200":
          description: Override stored; push to the agent started
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      managed: {type: boolean, nullable: true}
                      push: {type: string, example: started}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{agentID}/managed-updates/reapply:
    post:
      x-stability: internal
      x-required-scope: patches:write
      summary: Force re-push of the managed-updates control to an agent
      description: |
        Queues an immediate set-update-control push regardless of cooldown.
        Used when the panel shows drift between intent and applied state.
      operationId: reapplyAgentManagedUpdates
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "202":
          description: Reapply queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: reapply queued}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
  /agents/{agentID}/patch-policy:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get the patch policy assigned to an agent
      description: |
        Returns the direct assignment plus the effective (inherited) policy
        so the picker can show both without a second round-trip.
      operationId: getAgentPatchPolicy
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Direct + effective patch-policy assignment
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &PatchPolicyAssignmentView
                    type: object
                    properties:
                      policy_id: {type: string, format: uuid, nullable: true}
                      effective_policy_id: {type: string, format: uuid, nullable: true}
                      effective_policy_name: {type: string, nullable: true}
                      effective_source:
                        type: string
                        nullable: true
                        enum: [organization, client, site, agent, null]
                      block_inheritance:
                        type: boolean
                        nullable: true
                        description: Only meaningful at agent scope
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Assign or clear the patch policy for an agent
      description: |
        Sets the agent's direct patch-policy assignment. `patch_policy_id`
        null clears the assignment (inherit). `block_inheritance` is only
        honoured at agent scope and is ignored on the client/site/org
        endpoints.
      operationId: assignAgentPatchPolicy
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema: &PatchPolicyAssignmentRequest
              type: object
              properties:
                patch_policy_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Policy to assign; null clears the assignment
                block_inheritance:
                  type: boolean
                  nullable: true
                  description: Agent scope only; stop inheriting from broader scopes
      responses:
        "204":
          description: Assignment updated
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{agentID}/patches:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get patch install history for an agent
      description: |
        Returns up to the 500 most recent patch_history rows for the agent,
        newest first, with install attribution (manual actor or policy
        name) resolved. No pagination.
      operationId: listAgentPatchHistory
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Patch install history (max 500 rows)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        agent_id: {type: string, format: uuid}
                        kb_id: {type: string}
                        title: {type: string}
                        severity: {type: string}
                        status: {type: string}
                        installed_at: {type: string, format: date-time, nullable: true}
                        created_at: {type: string, format: date-time}
                        source:
                          type: string
                          nullable: true
                          description: '"manual" or "policy"; null for legacy rows'
                        actor_name: {type: string, nullable: true}
                        policy_name: {type: string, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{agentID}/patches/available:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: List cached available patches for an agent
      description: |
        Returns scan results cached in agent_patches (no live scan),
        ordered by severity, plus the agent's last scan time and whether
        Windows Update is RMM-managed on this endpoint.
      operationId: listAvailableAgentPatches
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Cached available patches
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      patches:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            agent_id: {type: string, format: uuid}
                            identifier: {type: string}
                            title: {type: string}
                            severity: {type: string}
                            categories:
                              type: array
                              items: {type: string}
                            size_bytes: {type: integer, format: int64}
                            is_downloaded: {type: boolean}
                            classification: {type: string}
                            platform: {type: string}
                            status: {type: string}
                            excluded_reason: {type: string, nullable: true}
                            metadata: {type: object}
                            first_seen_at: {type: string, format: date-time}
                            last_seen_at: {type: string, format: date-time}
                            scan_updated_at: {type: string, format: date-time}
                      last_scan_at: {type: string, format: date-time, nullable: true}
                      managed_updates: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{agentID}/patches/install:
    post:
      x-stability: internal
      x-required-scope: patches:write
      summary: Queue patch installation on an agent
      description: |
        Enqueues a durable River job that dispatches the install to the
        agent, tracks progress, and records history. Identifiers excluded
        by the effective patch policy are filtered out unless `force` is
        set; 400 with code `ALL_EXCLUDED` when every requested update is
        excluded. Poll `install-status` for progress.
      operationId: installAgentPatches
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kb_ids:
                  type: array
                  items: {type: string}
                  description: Windows KB IDs to install
                identifiers:
                  type: array
                  items: {type: string}
                  description: Cross-platform alternative to kb_ids (takes precedence when both are sent)
                force:
                  type: boolean
                  description: Bypass policy exclusion checks
      responses:
        "202":
          description: Install queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: accepted}
                      message: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/patches/install-status:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get progress of the latest patch install job
      description: |
        Returns the active (or most recent) patch_install_progress row for
        the agent. When no install job has ever run, returns
        `{"data":{"status":"none"}}`.
      operationId: getAgentPatchInstallStatus
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Install progress (or status "none")
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string}
                      total: {type: integer}
                      installed: {type: integer}
                      failed: {type: integer}
                      error_msg: {type: string, nullable: true}
                      started_at: {type: string, format: date-time, nullable: true}
                      completed_at: {type: string, format: date-time, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{agentID}/patches/scan:
    post:
      x-stability: internal
      x-required-scope: patches:write
      summary: Run a live patch scan on an agent
      description: |
        Synchronously proxies a `patchscan` command to the agent over NATS
        (5 minute agent timeout) and returns the raw scan output under the
        `data` key. Results are also persisted to agent_patches for the
        cached `patches/available` endpoint.
      operationId: scanAgentPatches
      tags: [Patches]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Scan results (agent's raw scan JSON under data)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      updates:
                        type: array
                        items:
                          type: object
                          properties:
                            title: {type: string}
                            kb_id: {type: string}
                            severity: {type: string}
                            size_bytes: {type: integer, format: int64}
                            is_downloaded: {type: boolean}
                            categories:
                              type: array
                              items: {type: string}
                            update_id: {type: string}
                            classification: {type: string}
                            description: {type: string}
                            release_date: {type: string}
                            eula_accepted: {type: boolean}
                            is_hidden: {type: boolean}
                            is_mandatory: {type: boolean}
                            support_url: {type: string}
                            update_type: {type: string}
                            wsus_managed: {type: boolean}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/policy:
    put:
      x-stability: stable
      x-required-scope: policies:write
      summary: Assign a direct policy to an agent
      description: |
        Sets the agent's direct policy and block_policy_inheritance flag,
        then resolves and re-materialises the agent's effective policy
        asynchronously. Set policy_id to null to clear the direct
        assignment (the agent then inherits from site/client/org).
      operationId: assignAgentPolicy
      tags: [Policies]
      parameters:
        - name: agentID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                policy_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Direct policy; null clears it
                block_policy_inheritance:
                  type: boolean
                  default: false
                  description: When true the agent ignores site/client/org policies
      responses:
        "200":
          description: Agent policy updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /agents/{agentID}/primary-contact:
    parameters:
      - name: agentID
        in: path
        required: true
        schema: {type: string, format: uuid}
    put:
      x-stability: internal
      x-required-scope: agents:write
      summary: Set or clear the agent's primary contact
      description: contact_id null clears the primary flag.
      operationId: setAgentPrimaryContact
      tags: [Agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                contact_id:
                  type: string
                  format: uuid
                  nullable: true
      responses:
        "200":
          description: Primary contact updated
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{agentID}/processes:
    get:
      x-stability: internal
      x-required-scope: agents:execute
      summary: List running processes on an agent (live)
      description: |
        Proxies a `processlist` command to the agent over NATS (30s agent
        timeout) and returns the agent's raw JSON array — NOT wrapped in
        the `{data:...}` envelope.
      operationId: listAgentProcesses
      tags: [Agent Operations]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Raw process array from the agent (no envelope)
          content:
            application/json:
              schema:
                type: array
                items: {type: object}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/processes/{pid}/kill:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Kill a process on an agent
      description: |
        Sends a `processkill` command for the given PID via NATS
        request/reply. PID must be a positive integer below 4194304.
        No request body is required.
      operationId: killAgentProcess
      tags: [Agent Operations]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
        - name: pid
          in: path
          required: true
          schema: {type: integer, minimum: 1, maximum: 4194303}
      responses:
        "200":
          description: Process killed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: killed}
                      pid: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/recent-users:
    parameters:
      - name: agentID
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: List contacts recently seen logged in on the agent
      operationId: listAgentRecentUsers
      tags: [Agents]
      responses:
        "200":
          description: Recent users with session stats
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        contact_id: {type: string, format: uuid}
                        full_name: {type: string}
                        email: {type: string, nullable: true}
                        windows_username: {type: string, nullable: true}
                        first_seen: {type: string, format: date-time, nullable: true}
                        last_seen: {type: string, format: date-time, nullable: true}
                        session_count: {type: integer}
                        is_primary: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{agentID}/registry:
    get:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Browse a Windows registry path on an agent
      description: |
        Proxies a `regbrowse` command to the agent and returns the agent's
        raw browse result under the `data` key (subkeys and values for the
        given path).
      operationId: browseAgentRegistry
      tags: [Agent Operations]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
        - name: path
          in: query
          required: true
          description: Registry path to browse (e.g. HKLM\SOFTWARE)
          schema: {type: string}
      responses:
        "200":
          description: Registry browse result (agent's raw JSON under data)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: {type: object}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Create a registry key or value on an agent
      description: |
        Proxies a `regcreate` command. With only `path` set, creates a key;
        with `name`/`type`/`data`, creates a value. Audit-logged
        (`agent.registry.create`).
      operationId: createAgentRegistryEntry
      tags: [Agent Operations]
      parameters: &RegistryAgentIDParam
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path: {type: string}
                name: {type: string}
                type:
                  type: string
                  description: Registry value type (e.g. REG_SZ, REG_DWORD)
                data: {type: string}
      responses: &RegistryWriteResponses
        "200":
          description: Operation result (agent's raw JSON under data)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: {type: object}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
    put:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Modify a registry value on an agent
      description: Proxies a `regmodify` command. `path`, `name`, and `type` are required. Audit-logged (`agent.registry.modify`).
      operationId: modifyAgentRegistryValue
      tags: [Agent Operations]
      parameters: *RegistryAgentIDParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path, name, type]
              properties:
                path: {type: string}
                name: {type: string}
                type: {type: string}
                data: {type: string}
      responses: *RegistryWriteResponses
    delete:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Delete a registry key or value on an agent
      description: |
        Proxies a `regdelete` command. Set `is_key` true to delete the key
        at `path`; otherwise deletes the value `name` under `path`.
        Audit-logged (`agent.registry.delete`). Request body on DELETE is
        unusual but is how this endpoint works.
      operationId: deleteAgentRegistryEntry
      tags: [Agent Operations]
      parameters: *RegistryAgentIDParam
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path: {type: string}
                name: {type: string}
                is_key: {type: boolean}
      responses: *RegistryWriteResponses
  /agents/{agentID}/remote-desktop:
    get:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Open a remote-desktop WebSocket relay to an agent
      description: |
        Session-shaped endpoint: upgrades the request to a WebSocket and
        runs a bidirectional VNC relay between the browser and the agent
        over NATS. Creates a remote session record; enforces the org's
        concurrent-session limit (429 `SESSION_LIMIT`) and a 4-hour
        absolute session ceiling. Not usable as a plain REST call.
      operationId: connectRemoteDesktop
      tags: [Remote Desktop]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "101":
          description: Switching Protocols — WebSocket VNC relay established
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429":
          description: Concurrent remote session limit reached
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/runscript:
    post:
      x-stability: stable
      x-required-scope: scripts:execute
      summary: Run a script on a specific agent
      description: |
        Executes a stored script (script_id) or an inline body+shell on
        one agent. Exactly one of script_id / body is required. Stored
        snippet references and {{client.*}}/{{site.*}}/{{agent.*}} custom
        fields are resolved server-side. Timeout is capped at 3600s
        (inline runs default to 60s, stored scripts to their
        default_timeout).

        Synchronous mode (default) blocks on the agent reply and returns
        stdout/stderr inline — capped in practice by the 30s server write
        timeout. For anything longer set `async:true`: the call returns
        202 with an execution_id immediately; poll GET
        /script-runs/{executionID}/status or stream GET
        /script-runs/{executionID}/stream, and read the final result from
        run history. Every run (success, timeout or decode failure) is
        recorded in script run history.
      operationId: runScriptOnAgent
      tags: [Scripts]
      parameters:
        - name: agentID
          in: path
          required: true
          description: Agent primary key (UUID), not the NATS agent_id
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                script_id:
                  type: string
                  format: uuid
                  description: Stored script to run (mutually exclusive with body)
                body:
                  type: string
                  description: Inline script body (requires shell)
                shell:
                  type: string
                  enum: [powershell, cmd, bash, python, nushell]
                  description: Required when body is used
                args:
                  type: array
                  description: Argument list (overrides the script's default_args)
                  items: {type: string}
                env_vars:
                  type: array
                  description: KEY=VALUE strings (overrides default_env_vars)
                  items: {type: string}
                timeout:
                  type: integer
                  minimum: 1
                  maximum: 3600
                  description: Seconds; capped at 3600
                async:
                  type: boolean
                  default: false
                  description: Return an execution_id immediately instead of blocking
      responses:
        "200":
          description: Synchronous run completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      stdout: {type: string}
                      stderr: {type: string}
                      retcode: {type: integer}
                      execution_time:
                        type: number
                        description: Seconds
        "202":
          description: Async run dispatched (async:true)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      execution_id:
                        type: string
                        format: uuid
                        description: Use with /script-runs/{executionID}/status and /stream
        "400":
          description: Validation failure (VALIDATION_ERROR / SNIPPET_ERROR / INVALID_BODY)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Agent or script not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "504":
          description: Agent did not respond before the timeout (AGENT_TIMEOUT, sync mode only)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /agents/{agentID}/rustdesk:
    get:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Get RustDesk connection credentials for an agent
      description: |
        Returns the agent's RustDesk ID, decrypted one-time password,
        server URL and public key. 403 `RUSTDESK_DISABLED` when remote
        access is disabled for the agent (per-field scope hierarchy,
        default off); 404 `RUSTDESK_NOT_CONFIGURED` when the agent has no
        RustDesk identity. When the resolved config requires end-user
        approval, the request blocks while the tray prompts the user:
        denial or timeout-deny returns 403 `ACCESS_DENIED`, a missing tray
        returns 412 `REQ_AUTH_NO_TRAY`, and no agent response returns 504.
      operationId: getRustDeskConnection
      tags: [Remote Desktop]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: RustDesk connection details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      rustdesk_id: {type: string}
                      password: {type: string}
                      server_url: {type: string}
                      public_key: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "412":
          description: Require-approval is on but the tray app is not installed on the endpoint
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "503":
          description: NATS unavailable (cannot request end-user approval)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond to the approval request
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/rustdesk-config:
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: Resolve the effective RustDesk config for an agent
      description: |
        Walks the agent > site > client > organisation hierarchy per field
        and returns the effective configuration, plus an `inherited` view
        of what the agent would get without its own scope row.
      operationId: resolveAgentRustDeskConfig
      tags: [Remote Desktop]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Effective (resolved) RustDesk config
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &RustDeskResolvedConfig
                    type: object
                    properties:
                      enabled: {type: boolean}
                      auto_install: {type: boolean}
                      require_approval: {type: boolean}
                      approval_timeout: {type: integer}
                      timeout_action: {type: string, enum: [deny, allow]}
                      resolved_from:
                        type: string
                        description: Scope type that provided enabled/auto_install
                      resolved_scope:
                        type: string
                        description: Scope ID that provided enabled/auto_install
                      inherited:
                        type: object
                        nullable: true
                        description: 'Resolution excluding the agent''s own scope row (same shape, without a nested inherited)'
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{agentID}/script-history:
    get:
      x-stability: stable
      x-required-scope: scripts:read
      summary: Get script run history for an agent
      description: |
        Recent script runs on one agent, newest first, with the script
        name joined in ("(ad-hoc)" for inline runs). The body is a plain
        array for backward compatibility; pagination metadata is exposed
        via the X-Total-Count, X-Total-Pages, X-Page and X-Per-Page
        response headers.
      operationId: agentScriptHistory
      tags: [Scripts]
      parameters:
        - name: agentID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        "200":
          description: Script run history (array body, pagination in headers)
          headers:
            X-Total-Count:
              schema: {type: integer}
            X-Total-Pages:
              schema: {type: integer}
            X-Page:
              schema: {type: integer}
            X-Per-Page:
              schema: {type: integer}
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        script_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Null for inline (ad-hoc) runs
                        agent_id: {type: string, format: uuid}
                        user_id: {type: string, format: uuid, nullable: true}
                        stdout: {type: string, nullable: true}
                        stderr: {type: string, nullable: true}
                        retcode:
                          type: integer
                          description: -1 marks a synthetic failure row (timeout / decode error)
                        execution_time: {type: number, nullable: true}
                        created_at: {type: string, format: date-time}
                        script_name:
                          type: string
                          description: "(ad-hoc) when the run had no stored script"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /agents/{agentID}/services:
    get:
      x-stability: internal
      x-required-scope: agents:execute
      summary: List services on an agent (live)
      description: |
        Proxies a `servicelist` command to the agent over NATS (30s agent
        timeout) and returns the agent's raw JSON array — NOT wrapped in
        the `{data:...}` envelope.
      operationId: listAgentServices
      tags: [Agent Operations]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Raw service array from the agent (no envelope)
          content:
            application/json:
              schema:
                type: array
                items: {type: object}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/services/{name}/action:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Start, stop, or restart a service on an agent
      description: |
        Proxies a `serviceaction` command via NATS request/reply. The
        service name is validated against an allow-pattern at the API layer
        (defence in depth against command injection).
      operationId: actionAgentService
      tags: [Agent Operations]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
        - name: name
          in: path
          required: true
          description: 'Service name (pattern: alphanumeric start, then [a-zA-Z0-9._@-], max 256 chars)'
          schema: {type: string}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [start, stop, restart]
      responses:
        "200":
          description: Action applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: success}
                      service: {type: string}
                      action: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/software:
    get:
      x-stability: internal
      x-required-scope: agents:execute
      summary: List installed software on an agent
      description: |
        Returns the agent's software inventory from the database (sorted
        by name). If the inventory is empty or older than 24 hours a
        background rescan is triggered via NATS — the stale data is
        still returned immediately; poll again for fresh rows.
      operationId: listSoftwareByAgent
      tags: [Software]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Software inventory rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        agent_id: {type: string, format: uuid}
                        name: {type: string}
                        version: {type: string}
                        publisher: {type: string}
                        install_date:
                          type: string
                          description: ISO date (YYYY-MM-DD) where parseable; raw vendor string otherwise
                        updated_at: {type: string, format: date-time}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{agentID}/software/scan:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Run a software inventory scan on an agent
      description: |
        Synchronous scan: sends `softwarelist` to the agent via NATS
        request/reply (up to ~65s), replaces the stored inventory with
        the result, and returns the new item count. Despite the 202
        status the scan has already completed when the response arrives.
        No request body.
      operationId: scanSoftware
      tags: [Software]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "202":
          description: Scan completed and inventory persisted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [scan_complete]
                      count:
                        type: integer
                        description: Software items found
        "400":
          description: Invalid agent ID, or the agent's scan failed (SCAN_ERROR)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond (AGENT_TIMEOUT)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/tasks:
    parameters:
      - name: agentID
        in: path
        required: true
        description: Agent primary key (UUID), not the NATS agent_id
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: tasks:read
      summary: List tasks for an agent
      description: |
        Paginated list of tasks directly targeting this agent, ordered by
        name. Group-targeted tasks that may fan out to the agent are NOT
        included.
      operationId: listTasksByAgent
      tags: [Tasks]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        "200":
          description: Paginated task list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &Task
                          type: object
                          description: One row from the automated_tasks table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            agent_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Null for smart-group-targeted tasks
                            target_group_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Smart asset group target (mutually exclusive with agent_id)
                            policy_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Set when materialised from a policy task template
                            name: {type: string, maxLength: 255}
                            task_type:
                              type: string
                              enum: [manual, once, daily, weekly, monthly, on_boot, on_onboarding, on_check_fail]
                              description: |
                                Scheduled types run via the server scheduler;
                                on_boot/on_onboarding run agent-side;
                                on_check_fail fires when trigger_check_id reports
                                a failing result.
                            actions:
                              type: array
                              description: Ordered action list executed by the agent
                              items:
                                type: object
                                required: [type]
                                properties:
                                  type:
                                    type: string
                                    enum: [run_script, run_command]
                                  script_id:
                                    type: string
                                    format: uuid
                                    description: Required for run_script actions
                                  shell: {type: string}
                                  command:
                                    type: string
                                    description: Required for run_command actions
                                  args:
                                    type: array
                                    items: {type: string}
                                  timeout: {type: integer}
                            run_time:
                              type: string
                              nullable: true
                              description: "HH:MM, required for daily/weekly/monthly"
                            run_date:
                              type: string
                              format: date-time
                              nullable: true
                              description: Required for once tasks
                            weekly_days:
                              type: array
                              nullable: true
                              description: Days of week (0=Sunday..6=Saturday), required for weekly
                              items: {type: integer, minimum: 0, maximum: 6}
                            monthly_day:
                              type: integer
                              nullable: true
                              minimum: 1
                              maximum: 31
                              description: Required for monthly tasks
                            trigger_check_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Required for (and only valid on) on_check_fail tasks
                            enabled: {type: boolean}
                            continue_on_error: {type: boolean}
                            collect_all_output: {type: boolean}
                            skip_if_offline:
                              type: boolean
                              description: When true, offline agents are skipped rather than queued
                            alert_severity:
                              type: string
                              enum: [info, warning, error]
                            last_run: {type: string, format: date-time, nullable: true}
                            last_status:
                              type: string
                              nullable: true
                              enum: [pass, fail]
                            next_run:
                              type: string
                              format: date-time
                              nullable: true
                              description: Null for manual and event-driven task types
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination: &TaskPaginationBlock
                        type: object
                        properties:
                          page: {type: integer}
                          per_page: {type: integer}
                          total: {type: integer}
                          total_pages: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: tasks:write
      summary: Create a task on an agent
      description: |
        Legacy per-agent create: the target agent comes from the URL.
        Identical body and validation to POST /tasks, except agent_id (if
        also present in the body) must match the URL and target_group_id
        is rejected. Prefer POST /tasks for new integrations.
      operationId: createAgentTask
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema: &CreateTaskBody
              type: object
              required: [name, task_type, actions]
              properties:
                agent_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Single-agent target (mutually exclusive with target_group_id)
                target_group_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: |
                    Smart asset group target. Group tasks cannot use
                    event-driven task types (on_boot, on_onboarding,
                    on_check_fail). Oversized groups (>50,000 members) are
                    rejected with 409 GROUP_OVERSIZE.
                name: {type: string, maxLength: 255}
                task_type:
                  type: string
                  enum: [manual, once, daily, weekly, monthly, on_boot, on_onboarding, on_check_fail]
                actions:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [type]
                    properties:
                      type:
                        type: string
                        enum: [run_script, run_command]
                      script_id:
                        type: string
                        format: uuid
                        description: Required for run_script
                      shell: {type: string}
                      command:
                        type: string
                        description: Required for run_command
                      args:
                        type: array
                        items: {type: string}
                      timeout: {type: integer}
                run_time:
                  type: string
                  description: "HH:MM — required for daily/weekly/monthly"
                run_date:
                  type: string
                  format: date-time
                  description: Required for once tasks
                weekly_days:
                  type: array
                  description: Required for weekly tasks (0=Sunday..6=Saturday)
                  items: {type: integer, minimum: 0, maximum: 6}
                monthly_day:
                  type: integer
                  minimum: 1
                  maximum: 31
                  description: Required for monthly tasks
                trigger_check_id:
                  type: string
                  format: uuid
                  description: Required for (and only valid on) on_check_fail; must reference a check on the same agent
                enabled: {type: boolean, default: true}
                continue_on_error: {type: boolean, default: false}
                collect_all_output: {type: boolean, default: false}
                skip_if_offline: {type: boolean, default: false}
                alert_severity:
                  type: string
                  enum: [info, warning, error]
      responses:
        "201":
          description: Task created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Task
        "400":
          description: Validation failure (VALIDATION_ERROR / INVALID_BODY / INVALID_ID)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /agents/{agentID}/terminal:
    get:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Open a live-terminal WebSocket relay to an agent
      description: |
        Session-shaped endpoint: upgrades the request to a WebSocket,
        creates a terminal session (auto-replacing the caller's prior
        session on the same agent), dispatches terminal-open to the agent
        and runs a bidirectional PTY relay over NATS. Binary frames are
        multiplexed with a one-byte channel tag: 0x00 raw stdin/stdout
        bytes, 0x01 msgpack ctl frames (resize/heartbeat/close). Idle
        timeout 30 minutes; agent declared offline after a 30s heartbeat
        gap. Not usable as a plain REST call.
      operationId: connectAgentTerminal
      tags: [Terminal]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "101":
          description: Switching Protocols — WebSocket terminal relay established
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentID}/tray-enabled:
    put:
      x-stability: internal
      x-required-scope: tray:write
      summary: Toggle the tray app on a single agent
      description: |
        Quick per-agent enable/disable. Upserts an agent-scoped tray
        config row carrying only the `enabled` flag and pushes the
        resolved config to the agent via NATS. Disabling also clears the
        agent's reported `tray_version` so the agents list stops showing
        the tray as installed.
      operationId: setAgentTrayEnabled
      tags: [Tray]
      parameters:
        - name: agentID
          in: path
          required: true
          description: Agent primary key (UUID, not the NATS agent_id string)
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled:
                  type: boolean
      responses:
        "200":
          description: Toggle applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      agent_id: {type: string, format: uuid}
                      enabled: {type: boolean}
        "400":
          description: Invalid agent ID or request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{agentID}/trigger-update:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Trigger a self-update check on a single agent
      description: |
        Publishes a `checkupdate` command to the agent. If the agent is
        offline the command is queued in pending_agent_commands and
        dispatched when it reconnects (within 28 days) — the response
        status is "queued" instead of "dispatched". No request body.
      operationId: triggerSingleAgentUpdate
      tags: [Agent Operations]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Update check dispatched or queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [dispatched, queued]
                      message: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{agentId}/backups:
    get:
      x-stability: internal
      x-required-scope: backups:read
      summary: Get backup protection state for an agent
      description: |
        Returns the MSP360 protection state for one agent: the mapped
        MSP360 endpoint (if any) and every backup plan attached to that
        endpoint. When the agent has no mapped endpoint the response is
        `{protected: false, plans: []}` with `endpoint` omitted. Used by
        the asset detail "Backups" tab.
      operationId: getAgentBackups
      tags: [Backups]
      parameters:
        - name: agentId
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Protection state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      protected:
                        type: boolean
                      endpoint: &Msp360Endpoint
                        type: object
                        description: One MSP360-side endpoint (computer) with its agent mapping
                        properties:
                          id: {type: string, format: uuid}
                          hid:
                            type: string
                            description: MSP360 hardware ID
                          computer_name: {type: string}
                          company:
                            type: string
                            description: MSP360 company name (omitted when empty)
                          build_version:
                            type: string
                            description: MSP360 backup agent build (omitted when empty)
                          user_email:
                            type: string
                            description: MSP360 user email (omitted when empty)
                          agent_id:
                            type: string
                            format: uuid
                            nullable: true
                            description: Mapped OpsMerge agent (omitted when unmapped)
                          agent_hostname:
                            type: string
                            nullable: true
                            description: Hostname of the mapped agent (omitted when unmapped)
                          match_confidence:
                            type: string
                            description: How the mapping was made (e.g. none/auto match tiers/manual)
                          last_seen_at: {type: string, format: date-time}
                      plans:
                        type: array
                        items: &Msp360Plan
                          type: object
                          description: One MSP360 backup plan with its latest run outcome
                          properties:
                            id: {type: string, format: uuid}
                            endpoint_id: {type: string, format: uuid}
                            plan_id:
                              type: string
                              description: MSP360-side plan identifier
                            plan_name: {type: string}
                            plan_type: {type: string}
                            storage_type: {type: string}
                            last_status:
                              type: string
                              description: Latest run status as reported by MSP360 (e.g. success/warning/error)
                            last_error: {type: string}
                            last_start:
                              type: string
                              format: date-time
                              nullable: true
                            next_start:
                              type: string
                              format: date-time
                              nullable: true
                            duration: {type: string}
                            files_copied: {type: integer, format: int64}
                            files_failed: {type: integer, format: int64}
                            data_copied:
                              type: integer
                              format: int64
                              description: Bytes copied on the last run
                            total_data:
                              type: integer
                              format: int64
                              description: Total bytes under protection
                            detailed_report_url: {type: string}
                            computer_name: {type: string}
                            agent_id:
                              type: string
                              format: uuid
                              nullable: true
        "400":
          description: Invalid agent ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: stable
      x-required-scope: agents:read
      summary: Get agent details
      description: |
        Full agent record with decoded hardware JSONB (CPU, memory,
        disks, NICs, disk hardware, GPUs, machine info, platform extras)
        plus computed `status`. Nil hardware arrays serialise as `[]`.
      operationId: getAgent
      tags: [Agents]
      responses:
        "200":
          description: Agent details with system info
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      agent_id: {type: string}
                      hostname: {type: string, nullable: true}
                      display_name: {type: string, nullable: true}
                      description: {type: string, nullable: true}
                      operating_system: {type: string, nullable: true}
                      platform: {type: string, nullable: true}
                      platform_version: {type: string, nullable: true}
                      kernel_version: {type: string, nullable: true}
                      goarch: {type: string, nullable: true}
                      total_ram: {type: integer, format: int64, nullable: true}
                      boot_time: {type: integer, format: int64, nullable: true}
                      logged_in_user: {type: string, nullable: true}
                      public_ip: {type: string, nullable: true}
                      public_ipv4: {type: string, nullable: true}
                      public_ipv6: {type: string, nullable: true}
                      version: {type: string, nullable: true}
                      status:
                        type: string
                        enum: [online, disconnected, offline, unknown]
                      last_seen: {type: string, format: date-time, nullable: true}
                      cpu_info:
                        type: object
                        nullable: true
                        properties:
                          count: {type: integer}
                          model: {type: string}
                          usage: {type: number}
                      memory_info:
                        type: object
                        nullable: true
                        properties:
                          total: {type: integer, format: int64}
                          used: {type: integer, format: int64}
                          percent: {type: number}
                      disks:
                        type: array
                        items:
                          type: object
                          properties:
                            device: {type: string}
                            mount_point: {type: string}
                            fs_type: {type: string}
                            total: {type: integer, format: int64}
                            used: {type: integer, format: int64}
                            free: {type: integer, format: int64}
                            percent: {type: number}
                      network_interfaces:
                        type: array
                        items:
                          type: object
                          properties:
                            name: {type: string}
                            ips:
                              type: array
                              items: {type: string}
                            mac: {type: string}
                      disk_hardware:
                        type: array
                        items:
                          type: object
                          properties:
                            device: {type: string}
                            model: {type: string}
                            media_type: {type: string}
                            protocol: {type: string}
                            is_internal: {type: boolean}
                            manufacturer: {type: string}
                            serial_number: {type: string}
                            firmware_version: {type: string}
                            health_status: {type: string}
                            size_bytes: {type: integer, format: int64}
                      gpu_info:
                        type: array
                        items:
                          type: object
                          properties:
                            name: {type: string}
                            vram: {type: string}
                            vendor: {type: string}
                      machine_info:
                        type: object
                        nullable: true
                        properties:
                          manufacturer: {type: string}
                          model: {type: string}
                          serial_number: {type: string}
                          smbios_uuid: {type: string}
                      platform_extras:
                        type: object
                        nullable: true
                        description: "OS-specific extras (macOS security posture, battery, …)"
                      is_online: {type: boolean}
                      maintenance_mode: {type: boolean}
                      needs_reboot: {type: boolean}
                      rustdesk_id: {type: string, nullable: true}
                      smbios_uuid: {type: string, nullable: true}
                      bitlocker_mount_points:
                        type: array
                        items: {type: string}
                      site_id: {type: string, format: uuid}
                      site_name: {type: string}
                      client_name: {type: string}
                      client_id: {type: string, format: uuid}
                      asset_type: {type: string}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: stable
      x-required-scope: agents:write
      summary: Update agent metadata
      description: |
        Partial update of maintenance_mode, needs_reboot, site_id,
        display_name, and description. For display_name/description an
        explicit `null` (or empty/whitespace string) clears the value;
        an absent key leaves it untouched. Changing site_id re-resolves
        and re-materialises the agent's effective policy in the
        background. Audit-logged (`agent.update`).
      operationId: updateAgent
      tags: [Agents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                maintenance_mode: {type: boolean}
                needs_reboot: {type: boolean}
                site_id:
                  type: string
                  format: uuid
                  description: Target site (must exist in this tenant)
                display_name:
                  type: string
                  nullable: true
                  maxLength: 255
                description:
                  type: string
                  nullable: true
                  maxLength: 4000
      responses:
        "200":
          description: Agent updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      agent_id: {type: string}
                      hostname: {type: string, nullable: true}
                      display_name: {type: string, nullable: true}
                      description: {type: string, nullable: true}
                      maintenance_mode: {type: boolean}
                      needs_reboot: {type: boolean}
                      site_id: {type: string, format: uuid}
                      updated_at: {type: string, format: date-time}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    delete:
      x-stability: stable
      x-required-scope: agents:write
      summary: Delete an agent
      description: |
        Removes the agent row (and its document associations), then
        evicts the agent's NATS credential from the authorization list.
        Does NOT send an uninstall command — use POST
        /agents/{id}/uninstall for that. Audit-logged (`agent.delete`).
      operationId: deleteAgent
      tags: [Agents]
      responses:
        "204":
          description: Agent deleted
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{id}/bitdefender:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: Get Bitdefender state for an agent
      description: |
        Returns the cached Bitdefender-side endpoint state for one
        OpsMerge agent (most recent dispatch row). `data` is `null` when
        no install has ever been dispatched for the agent. Integration
        configuration lives under `/integrations/bitdefender/**` and is
        documented separately.
      operationId: getAgentBitdefenderStatus
      tags: [Endpoint Security]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Endpoint state (null when never dispatched)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    nullable: true
                    properties:
                      id: {type: string, format: uuid}
                      bd_company_id:
                        type: string
                        description: Bitdefender GravityZone company ID (omitted when empty)
                      bd_endpoint_id:
                        type: string
                        description: Bitdefender endpoint ID (empty until reconciliation matches it)
                      hostname: {type: string}
                      operating_system: {type: string}
                      install_status:
                        type: string
                        enum: [requested, downloading, installing, installed, failed]
                      install_message:
                        type: string
                        description: Failure detail (omitted when empty)
                      install_dispatched_at:
                        type: string
                        format: date-time
                        nullable: true
                      install_completed_at:
                        type: string
                        format: date-time
                        nullable: true
                      last_seen_at:
                        type: string
                        format: date-time
                        nullable: true
                      policy_id:
                        type: string
                        description: Assigned Bitdefender policy ID (omitted when empty)
                      updated_at: {type: string, format: date-time}
        "400":
          description: Invalid agent ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{id}/bitdefender/install:
    post:
      x-stability: internal
      x-required-scope: endpoint-security:write
      summary: Dispatch a Bitdefender install to an agent
      description: |
        Ships the `bitdefender-install` command to the target agent over
        NATS and waits for the result (the agent downloads and runs the
        client-specific installer silently as SYSTEM/root; the server
        waits up to ~10.5 minutes). The agent's client must already be
        provisioned and `active` in Bitdefender with cached installer
        URLs for the agent's platform (400 with codes
        BITDEFENDER_NOT_PROVISIONED / BITDEFENDER_NOT_READY /
        BITDEFENDER_NO_INSTALLERS / BITDEFENDER_PLATFORM_UNSUPPORTED
        otherwise). A non-zero installer exit returns 502 with an error
        object extended by `exit_code`, `stdout`, and `stderr`.
      operationId: dispatchBitdefenderInstall
      tags: [Endpoint Security]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Installer completed successfully on the agent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [installed]
                      stdout: {type: string}
                      exit: {type: integer}
        "400":
          description: Invalid agent ID, or the client is not ready for dispatch
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "502":
          description: Installer ran but exited non-zero (error carries exit_code/stdout/stderr)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: NATS not available
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "504":
          description: Agent did not respond within the dispatch window
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /agents/{id}/cmd:
    post:
      x-stability: stable
      x-required-scope: agents:execute
      summary: Execute a shell command on an agent
      description: |
        Synchronous NATS request/reply: blocks until the agent returns
        output or the timeout elapses. `timeout` is in seconds, defaults
        to 60 and is clamped to 3600. An unreachable agent returns 504
        (`AGENT_TIMEOUT`). Audit-logged (`agent.command.run`).
      operationId: sendCommand
      tags: [Agents]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [command]
              properties:
                command: {type: string}
                shell:
                  type: string
                  description: "Shell to run under (platform-dependent, e.g. cmd, powershell, bash)"
                timeout:
                  type: integer
                  default: 60
                  maximum: 3600
                  description: Seconds to wait for the command to complete
      responses:
        "200":
          description: Command output
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      stdout: {type: string}
                      stderr: {type: string}
                      retcode: {type: integer}
                      execution_time:
                        type: number
                        description: Seconds the command took on the agent
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond before the timeout (AGENT_TIMEOUT)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{id}/custom-fields:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: Get custom field values for an agent
      description: |
        Returns every agent-scoped field definition with the value (if any)
        stored for this agent.
      operationId: getAgentCustomFields
      tags: [CustomFields]
      responses:
        "200": &CustomFieldValueList
          description: Field values (every applicable definition, value may be null)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        field_id:
                          type: string
                          format: uuid
                        field_name:
                          type: string
                        field_type:
                          type: string
                          enum: [text, number, checkbox, dropdown]
                        value:
                          type: string
                          nullable: true
                        default_value:
                          type: string
                          nullable: true
    put:
      x-stability: internal
      x-required-scope: agents:write
      summary: Set custom field values for an agent
      description: |
        Upserts values from an array of `{field_id, value}` pairs. Values
        are validated against the field type (`number` must parse as a
        float, `checkbox` must be "true" or "false") and the definition's
        `applies_to` must match the entity type.
      operationId: setAgentCustomFields
      tags: [CustomFields]
      requestBody: &CustomFieldValuesUpdate
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                type: object
                required: [field_id]
                properties:
                  field_id:
                    type: string
                    format: uuid
                  value:
                    type: string
                    nullable: true
      responses:
        "200": &CustomFieldValuesUpdated
          description: Values updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: ok
        "400":
          $ref: "#/components/responses/ValidationError"
  /agents/{id}/live-metric:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: Get a live metric snapshot from the agent
      description: |
        Requests cpu, memory or disk over NATS with a short timeout;
        falls back to the last cached heartbeat value (stale=true) when
        the agent doesn't answer in time.
      operationId: getAgentLiveMetric
      tags: [Agents]
      parameters:
        - name: kind
          in: query
          required: true
          schema:
            type: string
            enum: [cpu, memory, disk]
      responses:
        "200":
          description: Metric snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      kind: {type: string}
                      ts: {type: string, format: date-time}
                      source: {type: string}
                      stale: {type: boolean}
                      value:
                        description: "Shape depends on kind: cpu {usage}, memory {total, used, percent}, disk array of {device, mount_point, fs_type, total, used, free, percent}"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{id}/maintenance:
    post:
      x-stability: stable
      x-required-scope: agents:execute
      summary: Toggle agent maintenance mode
      description: |
        Sets maintenance_mode on the agent record (no agent round-trip).
        Audit-logged (`agent.maintenance.set`).
      operationId: agentMaintenance
      tags: [Agents]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: {type: boolean}
      responses:
        "200":
          description: Maintenance mode updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      agent_id: {type: string}
                      hostname: {type: string, nullable: true}
                      maintenance_mode: {type: boolean}
                      updated_at: {type: string, format: date-time}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{id}/notes:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: internal
      x-required-scope: agents:write
      summary: List notes for an agent
      description: Paginated note list, newest first.
      operationId: listAgentNotes
      tags: [Notes]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated note list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &AgentNote
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            agent_id:
                              type: string
                              format: uuid
                            user_id:
                              type: string
                              format: uuid
                            username:
                              type: string
                            note:
                              type: string
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      x-stability: internal
      x-required-scope: agents:write
      summary: Add a note to an agent
      operationId: createAgentNote
      tags: [Notes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note:
                  type: string
      responses:
        "201":
          description: Note created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AgentNote
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{id}/notes/{noteID}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: noteID
        in: path
        required: true
        schema:
          type: string
          format: uuid
    put:
      x-stability: internal
      x-required-scope: agents:write
      summary: Update an agent note
      description: Only the note author or a superuser may edit (403 otherwise).
      operationId: updateAgentNote
      tags: [Notes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note:
                  type: string
      responses:
        "200":
          description: Note updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AgentNote
        "400":
          $ref: "#/components/responses/ValidationError"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      x-stability: internal
      x-required-scope: agents:write
      summary: Delete an agent note
      description: Only the note author or a superuser may delete (403 otherwise).
      operationId: deleteAgentNote
      tags: [Notes]
      responses:
        "204":
          description: Note deleted
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /agents/{id}/reboot:
    post:
      x-stability: stable
      x-required-scope: agents:execute
      summary: Reboot an agent
      description: |
        NATS request/reply — waits (up to 15s) for the agent to confirm
        it scheduled the reboot. A non-zero agent exit code returns 422
        (`AGENT_ERROR`) with the agent's failure detail. No request
        body. Audit-logged (`agent.reboot`).
      operationId: rebootAgent
      tags: [Agents]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Reboot confirmed by the agent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [confirmed]
                      stdout: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "422":
          description: Agent reported failure executing the reboot (AGENT_ERROR)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond (AGENT_TIMEOUT)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{id}/rotate-nats-creds:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: agents:write
      summary: Rotate one agent's NATS credentials
      description: |
        Regenerates the per-agent NATS password, rewrites users.conf and
        reloads NATS, then best-effort dispatches the new credential to
        the agent. No request body.
      operationId: rotateAgentNatsCreds
      tags: [Agents]
      responses:
        "202":
          description: Rotation accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: rotated}
                      dispatched: {type: boolean}
                      message: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{id}/scheduled-reboot:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Schedule a delayed reboot on an agent
      description: |
        Sends a `scheduled-reboot` command via NATS request/reply with a
        configurable delay. `delay_minutes` is clamped to 1-1440 and
        defaults to 5 when below 1. The agent's confirmation (or 422
        `AGENT_ERROR` with the agent's failure detail) is returned.
        Audit-logged (`agent.scheduled_reboot`).
      operationId: scheduledRebootAgent
      tags: [Agent Operations]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                delay_minutes:
                  type: integer
                  minimum: 1
                  maximum: 1440
                  default: 5
      responses: &PowerCommandResponses
        "200":
          description: Agent confirmed the command
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: confirmed}
                      stdout: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
        "422":
          description: Agent reported failure executing the command
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "503":
          description: NATS unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "504":
          description: Agent did not respond — it may be offline or unreachable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /agents/{id}/shutdown:
    post:
      x-stability: internal
      x-required-scope: agents:execute
      summary: Shut down an agent's endpoint
      description: |
        Sends a `shutdown` command via NATS request/reply and returns the
        agent's confirmation. Audit-logged (`agent.shutdown`). No request
        body.
      operationId: shutdownAgent
      tags: [Agent Operations]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses: *PowerCommandResponses
  /agents/{id}/threatdown:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: Get the agent's ThreatDown endpoint state
      description: |
        Returns the linked ThreatDown (Malwarebytes OneView) endpoint
        record for this agent, or installed=false when none is linked.
      operationId: getAgentThreatdown
      tags: [ThreatDown]
      responses:
        "200":
          description: Endpoint state (free-form map built from the OneView endpoint record)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{id}/threatdown/detections:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: List ThreatDown detections for an agent
      operationId: listAgentThreatdownDetections
      tags: [ThreatDown]
      responses:
        "200":
          description: Detection list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        td_detection_id: {type: string}
                        td_machine_id: {type: string, nullable: true}
                        threat_name: {type: string, nullable: true}
                        category: {type: string, nullable: true}
                        status: {type: string, nullable: true}
                        action_taken: {type: string, nullable: true}
                        path: {type: string, nullable: true}
                        process_name: {type: string, nullable: true}
                        machine_name: {type: string, nullable: true}
                        reported_at: {type: string, format: date-time, nullable: true}
                        created_at: {type: string, format: date-time}
                        acknowledged_at: {type: string, format: date-time, nullable: true}
                        acknowledged_by: {type: string, format: uuid, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /agents/{id}/threatdown/detections/{detectionID}/ack:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
      - name: detectionID
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: agents:write
      summary: Acknowledge a ThreatDown detection
      operationId: ackThreatdownDetection
      tags: [ThreatDown]
      responses:
        "200":
          description: Acknowledgement result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      acknowledged: {type: boolean}
                      already:
                        type: boolean
                        description: Present (true) when the detection was already acknowledged
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{id}/threatdown/install:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: agents:write
      summary: Install the ThreatDown agent on an endpoint
      description: |
        Dispatches the ThreatDown installer to the RMM agent over NATS
        and triggers a delayed post-install sync. No request body.
      operationId: installThreatdownAgent
      tags: [ThreatDown]
      responses:
        "200":
          description: Installer dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
                      stdout: {type: string}
                      stderr: {type: string}
                      exit_code: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{id}/threatdown/jobs/{jobID}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
      - name: jobID
        in: path
        required: true
        schema: {type: string}
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: Poll the status of a ThreatDown scan job
      operationId: getThreatdownJobStatus
      tags: [ThreatDown]
      responses:
        "200":
          description: OneView job record (free-form, proxied from the OneView API)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /agents/{id}/uninstall:
    post:
      x-stability: internal
      x-required-scope: agents:write
      summary: Uninstall an agent and delete its record
      description: |
        Sends an `uninstall` command to the agent (best-effort, 10s
        confirm window), deletes the agent record, blocklists the agent ID
        for 30 days so a stale install cannot re-register, and evicts its
        NATS credential. Proceeds with deletion even when the agent is
        offline — `status` is "uninstalled" when the command was
        delivered, "deleted" otherwise.
      operationId: uninstallAgent
      tags: [Agent Operations]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Agent record deleted; uninstall delivered or pending
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [uninstalled, deleted]
                      message: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /ai/scripts:
    post:
      x-stability: internal
      x-required-scope: scripts:write
      summary: Generate or improve a script with AI
      description: |
        Uses the org's configured OpenAI key (Settings -> AI) to create,
        fix or enhance a script. Returns the generated script body only.
      operationId: generateAiScript
      tags: [AI]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action, prompt, shell_type]
              properties:
                action:
                  type: string
                  enum: [create, fix, enhance]
                prompt:
                  type: string
                  description: Description of the desired script, or the error message to fix
                shell_type:
                  type: string
                  enum: [powershell, bash, python, cmd]
                script_body:
                  type: string
                  description: Existing script body (required for fix/enhance)
      responses:
        "200":
          description: Generated script
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      script: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /alert-templates:
    get:
      x-stability: stable
      x-required-scope: alerts:write
      summary: List alert templates
      description: |
        Returns all alert templates ordered by name, optionally filtered
        by kind (single value or comma-separated list; unknown values are
        rejected with 400 INVALID_KIND). The response uses the paginated
        envelope but is always a single page containing every row.
        Requires the alerts manage permission (alerts:write), including
        for this read.
      operationId: listAlertTemplates
      tags: [Alerts]
      parameters:
        - name: kind
          in: query
          description: "Single value or CSV of: asset_alert, ticket_notification, custom"
          schema: {type: string}
      responses:
        "200":
          description: Alert template list (single-page paginated envelope)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &AlertTemplate
                          type: object
                          description: One row from the alert_templates table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string, maxLength: 255}
                            email_enabled: {type: boolean}
                            email_recipients:
                              type: array
                              items: {type: string}
                            webhook_enabled: {type: boolean}
                            webhook_url: {type: string, nullable: true}
                            webhook_headers:
                              type: object
                              additionalProperties: {type: string}
                            sms_enabled: {type: boolean}
                            sms_recipients:
                              type: array
                              items: {type: string}
                            email_subject_template: {type: string, nullable: true}
                            email_body_template: {type: string, nullable: true}
                            webhook_body_template: {type: string, nullable: true}
                            sms_body_template: {type: string, nullable: true}
                            slack_enabled: {type: boolean}
                            slack_webhook_url: {type: string, nullable: true}
                            slack_channel: {type: string, nullable: true}
                            slack_body_template: {type: string, nullable: true}
                            teams_enabled: {type: boolean}
                            teams_webhook_url: {type: string, nullable: true}
                            teams_body_template: {type: string, nullable: true}
                            discord_enabled: {type: boolean}
                            discord_webhook_url: {type: string, nullable: true}
                            discord_body_template: {type: string, nullable: true}
                            is_default:
                              type: boolean
                              description: At most one template per org; setting it unsets the previous default
                            system_key:
                              type: string
                              nullable: true
                              description: Set on templates managed by the PSA notifications subsystem; null for user-authored templates
                            body_format:
                              type: string
                              enum: [text, html]
                            kind:
                              type: string
                              enum: [asset_alert, ticket_notification, custom]
                              description: UI organisation facet only — the alert engine does not filter by kind
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination: &AlertPaginationBlock
                        type: object
                        properties:
                          page: {type: integer}
                          per_page: {type: integer}
                          total: {type: integer}
                          total_pages: {type: integer}
        "400":
          description: Invalid kind filter (INVALID_KIND)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: alerts:write
      summary: Create an alert template
      description: |
        All channel toggles default to false; kind defaults to custom.
        Setting is_default unsets any existing default template.
      operationId: createAlertTemplate
      tags: [Alerts]
      requestBody:
        required: true
        content:
          application/json:
            schema: &AlertTemplateWrite
              type: object
              required: [name]
              properties:
                name: {type: string, maxLength: 255}
                email_enabled: {type: boolean, default: false}
                email_recipients:
                  type: array
                  items: {type: string}
                webhook_enabled: {type: boolean, default: false}
                webhook_url: {type: string}
                webhook_headers:
                  type: object
                  additionalProperties: {type: string}
                sms_enabled: {type: boolean, default: false}
                sms_recipients:
                  type: array
                  items: {type: string}
                email_subject_template: {type: string}
                email_body_template: {type: string}
                webhook_body_template: {type: string}
                sms_body_template: {type: string}
                slack_enabled: {type: boolean, default: false}
                slack_webhook_url: {type: string}
                slack_channel: {type: string}
                slack_body_template: {type: string}
                teams_enabled: {type: boolean, default: false}
                teams_webhook_url: {type: string}
                teams_body_template: {type: string}
                discord_enabled: {type: boolean, default: false}
                discord_webhook_url: {type: string}
                discord_body_template: {type: string}
                is_default: {type: boolean, default: false}
                kind:
                  type: string
                  enum: [asset_alert, ticket_notification, custom]
                  default: custom
      responses:
        "201":
          description: Template created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AlertTemplate
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /alert-templates/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    put:
      x-stability: stable
      x-required-scope: alerts:write
      summary: Update an alert template
      description: Partial update — only fields present in the body are changed.
      operationId: updateAlertTemplate
      tags: [Alerts]
      requestBody:
        required: true
        content:
          application/json:
            schema: *AlertTemplateWrite
      responses:
        "200":
          description: Template updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AlertTemplate
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: alerts:write
      summary: Delete an alert template
      operationId: deleteAlertTemplate
      tags: [Alerts]
      responses:
        "204":
          description: Template deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /alerts:
    get:
      x-stability: stable
      x-required-scope: alerts:read
      summary: List alerts
      description: |
        Paginated alert list, newest first, with joined agent hostname and
        check/task/domain names for display.
      operationId: listAlerts
      tags: [Alerts]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: status
          in: query
          schema:
            type: string
            enum: [active, acknowledged, resolved]
        - name: severity
          in: query
          schema:
            type: string
            enum: [info, warning, error]
        - name: alert_type
          in: query
          schema:
            type: string
            enum: [availability, check, task, anomaly, domain_monitor, mailbox, m365_security, msp360]
        - name: agent_id
          in: query
          schema: {type: string, format: uuid}
        - name: site_id
          in: query
          schema: {type: string, format: uuid}
        - name: client_id
          in: query
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Paginated alert list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          allOf:
                            - &Alert
                              type: object
                              description: One row from the alerts table
                              properties:
                                id: {type: string, format: uuid}
                                org_id: {type: string, format: uuid}
                                alert_type:
                                  type: string
                                  enum: [availability, check, task, anomaly, domain_monitor, mailbox, m365_security, msp360]
                                severity:
                                  type: string
                                  enum: [info, warning, error]
                                agent_id: {type: string, format: uuid, nullable: true}
                                check_id: {type: string, format: uuid, nullable: true}
                                task_id: {type: string, format: uuid, nullable: true}
                                domain_monitor_id: {type: string, format: uuid, nullable: true}
                                contact_id: {type: string, format: uuid, nullable: true}
                                alert_template_id: {type: string, format: uuid, nullable: true}
                                status:
                                  type: string
                                  enum: [active, acknowledged, resolved]
                                message: {type: string, nullable: true}
                                snoozed_until: {type: string, format: date-time, nullable: true}
                                email_sent: {type: boolean}
                                email_sent_at: {type: string, format: date-time, nullable: true}
                                webhook_sent: {type: boolean}
                                webhook_sent_at: {type: string, format: date-time, nullable: true}
                                sms_sent: {type: boolean}
                                sms_sent_at: {type: string, format: date-time, nullable: true}
                                slack_sent: {type: boolean}
                                slack_sent_at: {type: string, format: date-time, nullable: true}
                                teams_sent: {type: boolean}
                                teams_sent_at: {type: string, format: date-time, nullable: true}
                                discord_sent: {type: boolean}
                                discord_sent_at: {type: string, format: date-time, nullable: true}
                                resolved_at: {type: string, format: date-time, nullable: true}
                                resolved_by: {type: string, format: uuid, nullable: true}
                                acknowledged_at: {type: string, format: date-time, nullable: true}
                                acknowledged_by: {type: string, format: uuid, nullable: true}
                                consecutive_failures: {type: integer}
                                created_at: {type: string, format: date-time}
                                updated_at: {type: string, format: date-time}
                            - type: object
                              description: List-only joined display fields
                              properties:
                                agent_hostname:
                                  type: string
                                  description: Empty string when the alert has no agent
                                check_name: {type: string, nullable: true}
                                task_name: {type: string, nullable: true}
                                domain_name: {type: string, nullable: true}
                      pagination: *AlertPaginationBlock
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /alerts/summary:
    get:
      x-stability: stable
      x-required-scope: alerts:read
      summary: Get alert count summary
      description: |
        Aggregate counts by status, plus per-severity counts across
        unresolved (active + acknowledged) alerts.
      operationId: alertSummary
      tags: [Alerts]
      responses:
        "200":
          description: Alert counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total_active: {type: integer}
                      total_acknowledged: {type: integer}
                      total_resolved: {type: integer}
                      info_count: {type: integer}
                      warning_count: {type: integer}
                      error_count: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /alerts/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: alerts:read
      summary: Get alert details
      operationId: getAlert
      tags: [Alerts]
      responses:
        "200":
          description: Alert details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Alert
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: alerts:write
      summary: Delete an alert
      operationId: deleteAlert
      tags: [Alerts]
      responses:
        "204":
          description: Alert deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /alerts/{id}/acknowledge:
    put:
      x-stability: stable
      x-required-scope: alerts:write
      summary: Acknowledge an alert
      description: |
        Transitions active -> acknowledged, recording the acting user and
        timestamp. Returns 404 if the alert does not exist OR is not in
        the active state. No request body.
      operationId: acknowledgeAlert
      tags: [Alerts]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Alert acknowledged
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Alert
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Alert not found or not in active state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /alerts/{id}/resolve:
    put:
      x-stability: stable
      x-required-scope: alerts:write
      summary: Resolve an alert
      description: |
        Transitions active or acknowledged -> resolved, recording the
        acting user and timestamp. Returns 404 if the alert does not
        exist or is already resolved. No request body.
      operationId: resolveAlert
      tags: [Alerts]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Alert resolved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Alert
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Alert not found or already resolved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /alerts/{id}/snooze:
    put:
      x-stability: stable
      x-required-scope: alerts:write
      summary: Snooze an alert
      description: |
        Sets snoozed_until to now + duration_minutes on an active or
        acknowledged alert. The status field is unchanged — snoozing only
        suppresses re-notification until the timestamp passes.
      operationId: snoozeAlert
      tags: [Alerts]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [duration_minutes]
              properties:
                duration_minutes:
                  type: integer
                  minimum: 1
                  maximum: 525600
                  description: Snooze duration in minutes (max 1 year)
      responses:
        "200":
          description: Alert snoozed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Alert
        "400":
          description: Validation failure (duration out of range)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Alert not found or already resolved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /anomaly/events:
    get:
      x-stability: internal
      x-required-scope: alerts:read
      summary: List anomaly events
      description: |
        Anomaly alerts enriched with agent hostname and check name.
        Uses limit/offset paging (default limit 50), not page/per_page.
      operationId: listAnomalyEvents
      tags: [Anomaly Detection]
      parameters:
        - name: limit
          in: query
          schema: {type: integer, default: 50}
        - name: offset
          in: query
          schema: {type: integer, default: 0}
        - name: severity
          in: query
          schema: {type: string}
      responses:
        "200":
          description: Anomaly event list (bare array, no pagination meta)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        agent_id: {type: string, format: uuid}
                        agent_hostname: {type: string}
                        check_id: {type: string, format: uuid, nullable: true}
                        check_name: {type: string, nullable: true}
                        check_type: {type: string}
                        severity: {type: string}
                        confidence_score: {type: number}
                        z_score: {type: number}
                        status: {type: string}
                        created_at: {type: string, format: date-time}
                        resolved_at: {type: string, format: date-time, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /anomaly/settings:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: List anomaly detection settings
      description: Per-check-type sensitivity configuration for the org.
      operationId: listAnomalySettings
      tags: [Anomaly Detection]
      responses:
        "200":
          description: Settings rows ordered by check_type
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &AnomalySettings
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        check_type: {type: string}
                        enabled: {type: boolean}
                        sensitivity:
                          type: string
                          enum: [low, medium, high]
                        window_hours: {type: integer}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /anomaly/settings/{checkType}:
    parameters:
      - name: checkType
        in: path
        required: true
        schema: {type: string}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update anomaly settings for one check type
      description: Partial update — only non-null fields are applied.
      operationId: updateAnomalySettings
      tags: [Anomaly Detection]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: {type: boolean, nullable: true}
                sensitivity:
                  type: string
                  enum: [low, medium, high]
                  nullable: true
                window_hours:
                  type: integer
                  minimum: 1
                  maximum: 8760
                  nullable: true
      responses:
        "200":
          description: Updated settings row
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AnomalySettings
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /anomaly/timeline:
    get:
      x-stability: internal
      x-required-scope: alerts:read
      summary: Get the anomaly alert timeline histogram
      operationId: getAnomalyTimeline
      tags: [Anomaly Detection]
      parameters:
        - name: window
          in: query
          schema:
            type: string
            enum: [24h, 7d, 30d]
            default: 24h
        - name: bucket
          in: query
          schema:
            type: string
            enum: [1h, 6h, 1d]
            default: 1h
      responses:
        "200":
          description: Time-bucketed counts split by severity
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        bucket: {type: string}
                        count: {type: integer}
                        info_count: {type: integer}
                        warning_count: {type: integer}
                        error_count: {type: integer}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /api-keys:
    get:
      x-stability: stable
      summary: List API keys
      description: |
        Returns the caller's active (non-revoked) personal access tokens,
        newest first. The raw key is never returned — only the prefix.
        Session-only: API keys themselves are rejected with 403
        `API_KEY_FORBIDDEN` (a key must never list, mint, or revoke keys).
        MSP staff only — client-portal users receive 403 `FORBIDDEN`.
      operationId: listApiKeys
      tags: [API Keys]
      responses:
        "200":
          description: Active keys for the authenticated user
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            name: {type: string, example: "n8n integration"}
                            key_prefix: {type: string, example: om_3f9a}
                            created_at: {type: string, format: date-time}
                            expires_at: {type: string, format: date-time, nullable: true}
                            last_used_at: {type: string, format: date-time, nullable: true}
                            ip_address:
                              type: string
                              nullable: true
                              description: Last source IP the key was used from
                            scopes:
                              type: array
                              nullable: true
                              description: 'null = unrestricted key (role ceiling still applies)'
                              items: {type: string, example: 'psa-tickets:read'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: stable
      summary: Create an API key
      description: |
        Generates a new personal access token. The raw key is returned
        exactly once in this response and is never retrievable again.
        Omit `scopes` entirely for an unrestricted key (the creating
        user's role ceiling still applies); an explicit empty array is
        rejected with 422. Unknown scopes return 422 with
        `details.invalid_scope` naming the offender. Session-only: API
        keys are rejected with 403 `API_KEY_FORBIDDEN`. MSP staff only —
        client-portal users receive 403 `FORBIDDEN`.
      operationId: createApiKey
      tags: [API Keys]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string, example: "n8n integration"}
                expires_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: 'Optional ISO 8601 expiry; must be in the future'
                scopes:
                  type: array
                  description: 'Optional family:level scope list; omit for full role access'
                  items: {type: string, example: 'agents:read'}
      responses:
        "201":
          description: Key created — the raw key appears here once, never again
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      key:
                        type: string
                        description: The full raw API key (om_ prefix). Store it now.
                      id: {type: string, format: uuid}
                      name: {type: string}
                      key_prefix: {type: string, example: om_3f9a}
                      created_at: {type: string, format: date-time}
                      expires_at: {type: string, format: date-time, nullable: true}
                      scopes:
                        type: array
                        nullable: true
                        items: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "422":
          description: Empty scope array or unknown scope (details.invalid_scope names it)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /api-keys/scopes:
    get:
      x-stability: stable
      summary: List the API key scope catalogue
      description: |
        Returns the canonical scope catalogue that feeds the settings-page
        scope picker: one entry per scope family with its UI group and the
        levels available in that family, sorted by group then family.
        Levels are ordered read < write < execute. Session-only like the
        rest of the api-keys surface (403 `API_KEY_FORBIDDEN` for keys);
        MSP staff only — client-portal users receive 403 `FORBIDDEN`.
      operationId: listApiKeyScopes
      tags: [API Keys]
      responses:
        "200":
          description: Scope families, sorted by group then family
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        family: {type: string, example: psa-tickets}
                        group:
                          type: string
                          enum: [rmm, psa]
                        levels:
                          type: array
                          items:
                            type: string
                            enum: [read, write, execute]
              example:
                data:
                  - family: psa-chat
                    group: psa
                    levels: [write]
                  - family: agents
                    group: rmm
                    levels: [read, write, execute]
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /api-keys/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    delete:
      x-stability: stable
      summary: Revoke an API key
      description: |
        Soft-revokes a key (sets `revoked_at`); ownership is enforced via
        the authenticated user. 404 covers not-found, not-owned, and
        already-revoked alike. Session-only (403 `API_KEY_FORBIDDEN` for
        keys — privilege persistence guard). MSP staff only —
        client-portal users receive 403 `FORBIDDEN`.
      operationId: revokeApiKey
      tags: [API Keys]
      responses:
        "204":
          description: Key revoked
        "400":
          description: Invalid key ID
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /asset-groups:
    get:
      x-stability: beta
      x-required-scope: asset-groups:read
      summary: List asset groups
      description: |
        Returns every smart asset group in the organisation — system-seeded
        groups first, then by category and name. NOT paginated: the full set
        is returned as `{items}` with no `total` or pagination metadata.
        System groups (`is_system: true`) are read-only; clone them to
        customise.
      operationId: listAssetGroups
      tags: [Asset Groups]
      responses:
        "200":
          description: Asset group list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &AssetGroup
                          type: object
                          description: One smart asset group with live bookkeeping
                          properties:
                            id: {type: string, format: uuid}
                            name: {type: string, maxLength: 255}
                            description: {type: string, nullable: true}
                            category:
                              type: string
                              enum: [system, security, compliance, patching, operational, custom]
                              description: "'system' is reserved for seeded defaults and rejected on create/update"
                            criteria: &AssetGroupCriteria
                              type: object
                              description: |
                                Smart-group predicate tree (the filter DSL). A node is
                                EITHER a logic group or a leaf condition — never both,
                                never neither:

                                Logic group — `{"logic": "and"|"or"|"not", "conditions": [<node>, ...]}`
                                * `conditions` is a non-empty array of nested nodes (recursive)
                                * `not` takes EXACTLY one condition

                                Leaf — `{"field": "<name>", "op": "<op>", "value": <json>}`
                                * `field` must be a name from `GET /asset-groups/fields`.
                                  Bare names are agent columns (`hostname`,
                                  `operating_system`, `platform`, `asset_type`,
                                  `is_online`, `is_billable`, `maintenance_mode`,
                                  `needs_reboot`, `last_seen`, `version`, ...);
                                  namespaced fields join other data: `client.*`,
                                  `site.*`, `td.*` (ThreatDown), `bd.*` (Bitdefender),
                                  `patches.*`, `alerts.*`, `software.*`,
                                  `heartbeat.*`, `policy.id`, and `custom.<name>` for
                                  tenant custom fields.
                                * `value` shape depends on `op`:
                                  - eq/ne/gt/lt/gte/lte: scalar matching the field
                                    type (string, number, or boolean)
                                  - in/not_in: non-empty JSON array of scalars
                                  - contains/starts_with/ends_with: string
                                    (string/text fields only)
                                  - between: exactly-2-element array `[low, high]`
                                  - older_than/newer_than: duration string —
                                    `"7d"` day shorthand or a Go duration such as
                                    `"24h"` / `"90m"` (timestamp fields only)
                                  - is_null/is_not_null: `value` omitted entirely
                                    (ignored if present)

                                Parsing is strict: unknown fields/ops, empty
                                `conditions`, `not` with more than one condition, or
                                a node carrying both `logic` and `field` are rejected
                                with 400 CRITERIA_INVALID and a JSON-path pointer to
                                the offending node (e.g. `$.conditions[1].op`).
                              properties:
                                logic:
                                  type: string
                                  enum: [and, or, not]
                                  description: Present on logic-group nodes only
                                conditions:
                                  type: array
                                  description: Nested predicate nodes (same shape, recursive)
                                  items:
                                    type: object
                                field:
                                  type: string
                                  description: Present on leaf nodes only
                                  example: operating_system
                                op:
                                  type: string
                                  enum: [eq, ne, in, not_in, contains, starts_with, ends_with, gt, lt, gte, lte, between, is_null, is_not_null, older_than, newer_than]
                                value:
                                  description: "Any JSON value; shape is op-dependent (see above)"
                            is_system:
                              type: boolean
                              description: Seeded default; PUT/DELETE refused with 422 SYSTEM_READONLY
                            oversize:
                              type: boolean
                              description: Membership exceeded the 50,000-agent cap; group is excluded from recompute-all
                            member_count: {type: integer}
                            last_computed_at: {type: string, format: date-time, nullable: true}
                            last_compute_ms: {type: integer, nullable: true}
                            last_error:
                              type: string
                              nullable: true
                              description: Last evaluation failure, if any
                            version:
                              type: integer
                              description: Bumped on every criteria/metadata update
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
              example:
                data:
                  items:
                    - &AssetGroupExample
                      id: "3f9c2b1a-8d4e-4c5f-9a6b-1e2d3c4b5a69"
                      name: "Servers missing critical patches"
                      description: "Windows servers with at least one critical patch pending"
                      category: patching
                      criteria:
                        logic: and
                        conditions:
                          - field: asset_type
                            op: in
                            value: [standard_server, critical_server, domain_controller]
                          - field: operating_system
                            op: contains
                            value: "Windows Server"
                          - field: patches.has_critical_pending
                            op: eq
                            value: true
                      is_system: false
                      oversize: false
                      member_count: 14
                      last_computed_at: "2026-06-11T08:30:12Z"
                      last_compute_ms: 41
                      last_error: null
                      version: 3
                      created_at: "2026-05-29T10:02:44Z"
                      updated_at: "2026-06-11T08:30:12Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: asset-groups:write
      summary: Create an asset group
      description: |
        Creates a custom smart group. The criteria document is parsed and
        compile-checked before saving (400 CRITERIA_INVALID on failure), then
        membership is evaluated immediately so the response carries a real
        `member_count`. If that first evaluation cannot be read back the
        response degrades to just `{id, name}`. `category` defaults to
        `custom`; `system` is reserved for seeded defaults and rejected.
        Group names are unique per organisation (409 CONFLICT).
      operationId: createAssetGroup
      tags: [Asset Groups]
      requestBody:
        required: true
        content:
          application/json:
            schema: &AssetGroupWriteBody
              type: object
              required: [name, criteria]
              properties:
                name: {type: string, maxLength: 255}
                description:
                  type: string
                  description: Empty string is stored as null
                category:
                  type: string
                  enum: [security, compliance, patching, operational, custom]
                  default: custom
                  description: "'system' is rejected with 400"
                criteria: *AssetGroupCriteria
            example:
              name: "Laptops offline 7+ days"
              description: "Workstations not seen in a week"
              category: operational
              criteria:
                logic: and
                conditions:
                  - field: asset_type
                    op: eq
                    value: workstation
                  - field: last_seen
                    op: older_than
                    value: "7d"
      responses:
        "201":
          description: Group created (with freshly evaluated member_count)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AssetGroup
              example:
                data: *AssetGroupExample
        "400":
          description: Validation failure (BAD_REQUEST) or criteria parse/compile failure (CRITERIA_INVALID)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: A group with that name already exists in this organisation (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /asset-groups/fields:
    get:
      x-stability: beta
      x-required-scope: asset-groups:read
      summary: List queryable criteria fields
      description: |
        Returns the field catalogue for the criteria builder: every field
        usable in a smart-group predicate, its type, namespace, the operators
        it supports, and the value set for enum fields. Tenant custom fields
        (`applies_to: agent`) are merged in as `custom.<name>` entries —
        number-typed definitions get numeric ops, checkbox fields compare
        against the literal strings "true"/"false", dropdowns become enums.
      operationId: listAssetGroupFields
      tags: [Asset Groups]
      responses:
        "200":
          description: Field catalogue
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      fields:
                        type: array
                        items:
                          type: object
                          properties:
                            name:
                              type: string
                              description: Namespaced field name to use in criteria leaves
                            label: {type: string}
                            namespace:
                              type: string
                              description: Empty for bare agent columns; otherwise the join namespace (client, site, td, bd, patches, alerts, software, heartbeat, custom, ...)
                            type:
                              type: string
                              enum: [string, text, bool, int, uuid, timestamp, enum]
                            ops:
                              type: array
                              items: {type: string}
                              description: Operators legal on this field
                            enum_values:
                              type: array
                              items: {type: string}
                              description: Allowed values when type is enum
              example:
                data:
                  fields:
                    - name: hostname
                      label: Hostname
                      namespace: ""
                      type: string
                      ops: [eq, ne, in, not_in, contains, starts_with, ends_with, is_null, is_not_null]
                    - name: asset_type
                      label: Asset type
                      namespace: ""
                      type: enum
                      ops: [eq, ne, in, not_in]
                      enum_values: [workstation, standard_server, critical_server, domain_controller, network_device, mobile_device, printer, other, hypervisor]
                    - name: patches.has_critical_pending
                      label: Has critical patches pending
                      namespace: patches
                      type: bool
                      ops: [eq, ne]
                    - name: "custom.Warranty Provider"
                      label: "Custom: Warranty Provider"
                      namespace: custom
                      type: string
                      ops: [eq, ne, contains, starts_with, ends_with, is_null, is_not_null]
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /asset-groups/preview:
    post:
      x-stability: beta
      x-required-scope: asset-groups:write
      summary: Preview criteria without saving
      description: |
        Compiles a criteria document and returns the current match count plus
        a sample of up to 10 matching agents (ordered by hostname). Nothing
        is persisted — this backs the builder UI's "currently matches N
        agents" readout. Note this requires the write scope despite being
        read-only, because it shares the manage permission gate.
      operationId: previewAssetGroup
      tags: [Asset Groups]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [criteria]
              properties:
                criteria: *AssetGroupCriteria
            example:
              criteria:
                logic: and
                conditions:
                  - field: heartbeat.disk_free_pct
                    op: lt
                    value: 10
                  - field: is_online
                    op: eq
                    value: true
      responses:
        "200":
          description: Match count and sample
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      count:
                        type: integer
                        description: Total agents currently matching the criteria
                      oversize:
                        type: boolean
                        description: true when count exceeds the 50,000-member cap
                      sample:
                        type: array
                        description: Up to 10 matching agents, ordered by hostname
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            hostname: {type: string, nullable: true}
                            operating_system: {type: string, nullable: true}
              example:
                data:
                  count: 3
                  oversize: false
                  sample:
                    - id: "b4dca6c1-2e7f-4b8a-9d0c-5f6a7b8c9d0e"
                      hostname: "GREENDALE-FS01"
                      operating_system: "Microsoft Windows Server 2022 Standard"
        "400":
          description: Missing criteria (BAD_REQUEST) or parse/compile failure (CRITERIA_INVALID)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /asset-groups/recompute-all:
    post:
      x-stability: beta
      x-required-scope: asset-groups:write
      summary: Recompute every asset group
      description: |
        Synchronously re-evaluates membership for every non-oversize group in
        the organisation, capped at 100 groups per call. Groups beyond the
        cap (and any failures) are picked up by the 15-minute periodic sweep.
      operationId: recomputeAllAssetGroups
      tags: [Asset Groups]
      responses:
        "200":
          description: Sweep summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      scanned:
                        type: integer
                        description: Groups picked up (max 100)
                      ok: {type: integer}
                      failed: {type: integer}
              example:
                data:
                  scanned: 12
                  ok: 12
                  failed: 0
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /asset-groups/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: asset-groups:read
      summary: Get an asset group
      operationId: getAssetGroup
      tags: [Asset Groups]
      responses:
        "200":
          description: Asset group detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AssetGroup
              example:
                data: *AssetGroupExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: beta
      x-required-scope: asset-groups:write
      summary: Update an asset group
      description: |
        Full replace of name/description/category/criteria on a custom group.
        Criteria are re-validated and re-compiled (400 CRITERIA_INVALID),
        `version` is bumped, and membership is re-evaluated immediately.
        System groups are refused with 422 SYSTEM_READONLY — clone instead.
      operationId: updateAssetGroup
      tags: [Asset Groups]
      requestBody:
        required: true
        content:
          application/json:
            schema: *AssetGroupWriteBody
      responses:
        "200":
          description: Updated group with fresh bookkeeping
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AssetGroup
        "400":
          description: Validation failure (BAD_REQUEST) or criteria failure (CRITERIA_INVALID)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: A group with that name already exists (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: System groups are read-only (SYSTEM_READONLY)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: asset-groups:write
      summary: Delete an asset group
      description: |
        Deletes a custom group. Refused with 409 GROUP_IN_USE while any
        automated task targets the group (retarget or delete the tasks
        first); alert-template references are nulled automatically. System
        groups are refused with 422 SYSTEM_READONLY.
      operationId: deleteAssetGroup
      tags: [Asset Groups]
      responses:
        "204":
          description: Group deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Group is the target of one or more automated tasks (GROUP_IN_USE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: System groups are read-only (SYSTEM_READONLY)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /asset-groups/{id}/clone:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: asset-groups:write
      summary: Clone an asset group
      description: |
        Copies a group (typically a system seed) into a new editable custom
        group. No request body. The copy is named "<original> (copy)",
        bumping to "(copy 2)", "(copy 3)" etc. until unique; criteria carry
        over verbatim, category `system` becomes `custom`, and the clone is
        never a system group. Membership is evaluated immediately.
      operationId: cloneAssetGroup
      tags: [Asset Groups]
      responses:
        "201":
          description: Cloned group
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AssetGroup
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Could not find a unique copy name after 50 attempts (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /asset-groups/{id}/members:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: asset-groups:read
      summary: List group members
      description: |
        Paginated list of agents currently in the group, ordered by hostname.
        Uses `limit`/`offset` paging (not `page`/`per_page`) and returns
        `{items, total, limit, offset}` rather than `meta.pagination`.
      operationId: listAssetGroupMembers
      tags: [Asset Groups]
      parameters:
        - name: limit
          in: query
          description: Maximum rows to return (max 500)
          schema: {type: integer, default: 50, maximum: 500}
        - name: offset
          in: query
          description: Rows to skip
          schema: {type: integer, default: 0}
      responses:
        "200":
          description: Member list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            agent_id: {type: string, format: uuid}
                            hostname: {type: string, nullable: true}
                            operating_system: {type: string, nullable: true}
                            asset_type: {type: string}
                            is_online: {type: boolean}
                            last_seen: {type: string, format: date-time, nullable: true}
                            added_at:
                              type: string
                              format: date-time
                              description: When the agent entered the group
                      total: {type: integer}
                      limit: {type: integer}
                      offset: {type: integer}
              example:
                data:
                  items:
                    - agent_id: "b4dca6c1-2e7f-4b8a-9d0c-5f6a7b8c9d0e"
                      hostname: "GREENDALE-FS01"
                      operating_system: "Microsoft Windows Server 2022 Standard"
                      asset_type: standard_server
                      is_online: true
                      last_seen: "2026-06-11T09:14:02Z"
                      added_at: "2026-06-09T16:20:31Z"
                  total: 14
                  limit: 50
                  offset: 0
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /asset-groups/{id}/recompute:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: asset-groups:write
      summary: Recompute group membership now
      description: |
        Forces an immediate full re-evaluation of one group and returns the
        membership diff. Group entry/exit automation triggers and NATS
        membership events fire for any changes, exactly as on the periodic
        sweep.
      operationId: recomputeAssetGroup
      tags: [Asset Groups]
      responses:
        "200":
          description: Evaluation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      group_id: {type: string, format: uuid}
                      member_count: {type: integer}
                      added:
                        type: integer
                        description: Agents that entered the group this run
                      removed:
                        type: integer
                        description: Agents that left the group this run
                      oversize: {type: boolean}
                      compute_ms: {type: integer}
                      error:
                        type: string
                        description: Present only when membership could not be computed; also persisted to last_error
              example:
                data:
                  group_id: "3f9c2b1a-8d4e-4c5f-9a6b-1e2d3c4b5a69"
                  member_count: 15
                  added: 2
                  removed: 1
                  oversize: false
                  compute_ms: 38
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /audit-logs:
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: List audit logs for the current tenant
      description: |
        Paginated, filterable audit log list, newest first. Invalid
        `user_id` / `from` / `to` values return 400.
      operationId: listAuditLogs
      tags: [Audit]
      parameters:
        - name: action
          in: query
          schema:
            type: string
        - name: resource_type
          in: query
          schema:
            type: string
        - name: user_id
          in: query
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          description: RFC3339 timestamp lower bound
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: RFC3339 timestamp upper bound
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated audit log list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/AuditLog"
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
        "400":
          $ref: "#/components/responses/ValidationError"
  /audit-logs/export:
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Export audit logs (CSV or JSON)
      description: |
        Streaming compliance export. from and to are REQUIRED (RFC3339).
        Format comes from ?format=csv|json, falling back to the Accept
        header (text/csv), defaulting to JSON. Served as an attachment.
      operationId: exportAuditLogs
      tags: [Audit]
      parameters:
        - name: from
          in: query
          required: true
          schema: {type: string, format: date-time}
        - name: to
          in: query
          required: true
          schema: {type: string, format: date-time}
        - name: action
          in: query
          schema: {type: string}
        - name: resource_type
          in: query
          schema: {type: string}
        - name: user_id
          in: query
          schema: {type: string, format: uuid}
        - name: format
          in: query
          schema:
            type: string
            enum: [csv, json]
      responses:
        "200":
          description: Audit log export attachment
          content:
            text/csv:
              schema: {type: string}
            application/json:
              schema:
                type: array
                items: {type: object}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /auth/http-ticket:
    post:
      x-stability: internal
      summary: Issue a single-use HTTP iframe ticket
      description: |
        Mints a 60-second single-use ticket for embedded sub-apps the
        browser loads directly (currently the River UI iframe), where an
        Authorization header cannot be attached. Flow: the SPA POSTs here
        (Bearer-authed, empty body), then loads the iframe with
        `?ticket=<raw>`; the first iframe request redeems the ticket and
        sets a path-scoped 10-minute session cookie
        (`rmm_iframe_session`) so sub-resource requests keep
        authenticating. The raw ticket is returned once and never again.
        Session-only: API keys receive 403 `API_KEY_FORBIDDEN` (ticket
        issuance mints browser-session credentials).
      operationId: issueHttpTicket
      tags: [Account]
      responses:
        "201":
          description: Ticket issued (single-use, 60-second TTL)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ticket:
                        type: string
                        description: Raw ticket — pass as ?ticket= on the iframe URL
                      expires_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /auth/me:
    get:
      x-stability: internal
      summary: Get the authenticated user's profile
      description: |
        Returns the caller's profile in the same shape as the login
        response, plus tenant locale context, per-org feature gates, and
        platform-wide email gateway capability. Used by the SPA auth
        store to restore in-memory state on page reload.
      operationId: getAuthMe
      tags: [Account]
      responses:
        "200":
          description: Profile, tenant context, and feature gates
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      user:
                        type: object
                        description: User row plus subscription overlay
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          username: {type: string}
                          email: {type: string, format: email}
                          role_id: {type: string, format: uuid, nullable: true}
                          is_superuser: {type: boolean}
                          is_active: {type: boolean}
                          user_type:
                            type: string
                            example: msp_staff
                          contact_id:
                            type: string
                            format: uuid
                            nullable: true
                            description: Set for client-portal users only
                          last_login: {type: string, format: date-time, nullable: true}
                          created_at: {type: string, format: date-time}
                          updated_at: {type: string, format: date-time}
                          billing_status: {type: string, example: active}
                          tier: {type: string, example: crack_on}
                          trial_end: {type: string, format: date-time, nullable: true}
                          current_period_end: {type: string, format: date-time, nullable: true}
                          pending_tier:
                            type: string
                            description: Non-empty when a downgrade is scheduled
                          pending_tier_effective_at:
                            type: string
                            format: date-time
                            nullable: true
                          is_platform_admin: {type: boolean}
                      tenant:
                        type: object
                        description: Per-org locale context for money/date formatting
                        properties:
                          country: {type: string, example: GB}
                          currency: {type: string, example: GBP}
                          locale: {type: string, example: en_gb}
                          timezone: {type: string, example: Europe/London}
                      features:
                        type: object
                        description: Per-org feature gates the SPA reads
                        properties:
                          mdm_enabled: {type: boolean}
                          dial9_enabled: {type: boolean}
                      email:
                        type: object
                        description: 'Platform-wide email capability (same for every user on the cell)'
                        properties:
                          outbound_gateway: {type: string, example: hyvor}
                          delivery_reporting: {type: boolean}
                      region:
                        type: string
                        description: Cell region; absent when RMM_REGION is unset (dev)
                        example: eu
        "401": {$ref: '#/components/responses/Unauthorized'}
  /auth/ws-ticket:
    post:
      x-stability: internal
      summary: Issue a single-use WebSocket auth ticket
      description: |
        Mints a 60-second single-use ticket for WebSocket upgrades —
        browser WebSocket constructors cannot set an Authorization
        header, so the SPA POSTs here (Bearer-authed, empty body) and
        opens the socket with `?ticket=<raw>` (see
        `GET /chat-sessions/{id}/stream`). The ticket binds to the
        issuing user + org and dies the instant it is redeemed. The raw
        ticket is returned once and never again. Session-only: API keys
        receive 403 `API_KEY_FORBIDDEN`.
      operationId: issueWsTicket
      tags: [Account]
      responses:
        "201":
          description: Ticket issued (single-use, 60-second TTL)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ticket:
                        type: string
                        description: Raw ticket — pass as ?ticket= on the WS URL
                      expires_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /backups/endpoints:
    get:
      x-stability: internal
      x-required-scope: backups:read
      summary: List MSP360 backup endpoints
      description: |
        Returns every MSP360 endpoint (computer) synced for the org with
        its current agent mapping and joined agent hostname, ordered by
        computer name. Not paginated.
      operationId: listBackupEndpoints
      tags: [Backups]
      responses:
        "200":
          description: Endpoint list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *Msp360Endpoint
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /backups/endpoints/{id}/map:
    put:
      x-stability: internal
      x-required-scope: backups:write
      summary: Map an MSP360 endpoint to an agent
      description: |
        Pins a manual mapping between an MSP360 endpoint and an OpsMerge
        agent (match_confidence becomes `manual`), or clears the mapping
        when `agent_id` is null/empty. Manual mappings are preserved
        across sync cycles.
      operationId: mapBackupEndpoint
      tags: [Backups]
      parameters:
        - name: id
          in: path
          required: true
          description: MSP360 endpoint row ID
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                agent_id:
                  type: string
                  nullable: true
                  description: Agent UUID to map, or null/empty string to unmap
      responses:
        "204":
          description: Mapping updated
        "400":
          description: Invalid endpoint ID, body, or agent not found in this org
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /backups/plans:
    get:
      x-stability: internal
      x-required-scope: backups:read
      summary: List MSP360 backup plans
      description: |
        Returns every backup plan in the org joined to its endpoint and
        mapped agent, ordered by last start (newest first). Not
        paginated.
      operationId: listBackupPlans
      tags: [Backups]
      parameters:
        - name: status
          in: query
          description: Filter on the plan's last run status (matched exactly after lowercasing)
          schema:
            type: string
            example: error
      responses:
        "200":
          description: Plan list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *Msp360Plan
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /beta-invites/validate:
    get:
      x-stability: internal
      summary: Pre-flight check a beta invite code
      description: |
        Validates a `/signup/beta?code=` invite before the form is shown so a
        dead link fails fast. Always 200 — the body carries the outcome. The
        authoritative consume step happens during tenant provisioning, so a
        code that validates here can still lose a redemption race later.
      operationId: validateBetaInvite
      security: []
      tags: [Auth]
      parameters:
        - name: code
          in: query
          required: true
          schema: {type: string, maxLength: 64}
      responses:
        "200":
          description: Validation outcome
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      valid: {type: boolean}
                      reason:
                        type: string
                        enum: [unknown, redeemed, expired]
                        description: Present only when valid=false
        "429":
          $ref: "#/components/responses/RateLimited"
  /beta-request:
    post:
      x-stability: internal
      summary: Submit a beta-tester application
      description: |
        Public application form used while signups are paused
        (OPSMERGE_SIGNUP_MODE=beta). Requires a Cloudflare Turnstile token;
        includes a honeypot field (`website`) that triggers a silent 202 when
        filled. Emails the application to the operator — nothing is persisted.
        Shares the signup rate-limit bucket (3/hour per IP). Temporary route;
        removed when open signup resumes.
      operationId: submitBetaRequest
      security: []
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, company, cf_turnstile_response]
              properties:
                name: {type: string, maxLength: 2048}
                email: {type: string, format: email, maxLength: 2048}
                company: {type: string, maxLength: 2048}
                country: {type: string, maxLength: 2048}
                endpoints_managed: {type: string, maxLength: 2048}
                current_tools: {type: string, maxLength: 2048}
                why_interested: {type: string, maxLength: 2048}
                biggest_pain: {type: string, maxLength: 2048}
                website:
                  type: string
                  description: Honeypot — must be left empty; any value yields a silent 202 with no email sent
                cf_turnstile_response:
                  type: string
                  description: Cloudflare Turnstile token (always required on this route)
      responses:
        "202":
          description: "Application accepted (body is double-wrapped: `{data: {data: {accepted: true}}}`)"
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      data:
                        type: object
                        properties:
                          accepted: {type: boolean}
        "400":
          description: "Validation failure (VALIDATION with per-field errors map) or Turnstile rejected (BOT_CHECK_FAILED)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Turnstile verifier unreachable/misconfigured (BOT_CHECK_UNAVAILABLE) — retry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /billing/portal:
    post:
      x-stability: internal
      summary: Create a Stripe Customer Portal session
      description: |
        Creates a short-lived Stripe Customer Portal session for
        self-serve card updates, subscription changes, and invoice
        downloads; the frontend redirects to the returned URL. Requires
        an existing Stripe customer on the org's subscription row — 400
        `NO_CUSTOMER` if the org never completed a Checkout. This is
        OpsMerge platform billing (the MSP's own subscription), not
        MSP-to-client invoicing. DENIED to API keys: 403
        `API_KEY_FORBIDDEN`. Empty request body.
      operationId: createBillingPortalSession
      tags: [Platform Billing]
      responses:
        "200":
          description: Portal session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url:
                        type: string
                        format: uri
                        description: Hosted Stripe Customer Portal URL to redirect to
        "400":
          description: No Stripe customer on file (NO_CUSTOMER)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: Stripe error creating the portal session (STRIPE_ERROR)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "503":
          description: Billing provider unavailable (PROVIDER_UNAVAILABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /billing/public-pricing:
    get:
      x-stability: internal
      summary: Get public subscription pricing (public)
      description: |
        OpsMerge platform subscription price card for the signup page.
        Unauthenticated. GBP pence values.
      operationId: getPublicPricing
      tags: [Platform Billing]
      security: []
      responses:
        "200":
          description: Price card
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      currency: {type: string, example: gbp}
                      tiers:
                        type: object
                        additionalProperties:
                          type: object
                          properties:
                            monthly_pence: {type: integer}
                            annual_pence: {type: integer}
                            annual_per_month_pence: {type: integer}
                      asset_pack:
                        type: object
                        properties:
                          monthly_pence: {type: integer}
                          assets_per_unit: {type: integer}
  /billing/tier-change/confirm:
    post:
      x-stability: internal
      summary: Confirm a mid-cycle tier change
      description: |
        Executes the tier change previewed by
        `GET /billing/tier-change/preview`. Upgrades apply immediately
        (Stripe `always_invoice` — proration charged now); downgrades
        are scheduled for the current period end (no mid-cycle refund) —
        the response preview has `scheduled: true` and zeroed proration
        fields. Requires an active (non-dunning) subscription: suspended
        tenants get 402 and must clear the unpaid invoice via
        `/billing/upgrade` first. DENIED to API keys: 403
        `API_KEY_FORBIDDEN`.
      operationId: confirmTierChange
      tags: [Platform Billing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_tier]
              properties:
                new_tier:
                  type: string
                  enum: [ease_in, crack_on]
      responses:
        "200":
          description: Tier change applied (upgrade) or scheduled (downgrade)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      preview: &TierChangePreview
                        type: object
                        description: Proration breakdown (amounts in pence)
                        properties:
                          current_tier: {type: string, example: ease_in}
                          new_tier: {type: string, example: crack_on}
                          change_date: {type: string, format: date-time}
                          period_start: {type: string, format: date-time}
                          period_end: {type: string, format: date-time}
                          days_total: {type: integer}
                          days_remaining: {type: integer}
                          credit_cents:
                            type: integer
                            description: Unused portion of the current tier (upgrade only)
                          charge_cents:
                            type: integer
                            description: Prorated new-tier cost (upgrade only)
                          net_cents:
                            type: integer
                            description: charge_cents - credit_cents (0 for scheduled downgrades)
                          direction:
                            type: string
                            enum: [upgrade, downgrade]
                          currency_code: {type: string, example: GBP}
                          scheduled:
                            type: boolean
                            description: true = applies at effective_at (downgrades)
                          effective_at:
                            type: string
                            format: date-time
                            nullable: true
                          new_tier_monthly_cents:
                            type: integer
                            description: Full monthly price of the target tier
                      confirmed: {type: boolean, example: true}
        "400":
          description: Invalid body or new_tier (INVALID_TIER)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "402":
          description: Billing not active (TRIAL_EXPIRED / DUNNING_SUSPENDED / BILLING_REQUIRED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: No billable subscription, or already on this tier (SAME_TIER)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "422":
          description: Proration could not be computed (PRORATION_ERROR)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "502":
          description: Stripe subscription update failed (STRIPE_ERROR)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /billing/tier-change/preview:
    get:
      x-stability: internal
      summary: Preview a mid-cycle tier change
      description: |
        Computes the proration for switching tier mid-cycle using local
        math — no Stripe call, nothing is committed. Downgrades come back
        with `scheduled: true`, `effective_at` = current period end, and
        zeroed proration fields (no mid-cycle refund policy). DENIED to
        API keys: 403 `API_KEY_FORBIDDEN`.
      operationId: previewTierChange
      tags: [Platform Billing]
      parameters:
        - name: new_tier
          in: query
          required: true
          schema:
            type: string
            enum: [ease_in, crack_on]
      responses:
        "200":
          description: Proration preview (not committed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      preview: *TierChangePreview
        "400":
          description: Invalid new_tier (INVALID_TIER)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "402":
          description: Billing not active (TRIAL_EXPIRED / DUNNING_SUSPENDED / BILLING_REQUIRED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: No billable subscription (grandfathered or no period dates)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "422":
          description: Proration could not be computed (PRORATION_ERROR)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /billing/tier-change/scheduled:
    delete:
      x-stability: internal
      summary: Cancel a scheduled tier change
      description: |
        Reverts a pending (scheduled) downgrade: clears the pending tier
        and restores the Stripe subscription to the current tier's price
        at zero cost. 409 `NO_PENDING_CHANGE` when nothing is scheduled.
        DENIED to API keys: 403 `API_KEY_FORBIDDEN`.
      operationId: cancelScheduledTierChange
      tags: [Platform Billing]
      responses:
        "200":
          description: Pending change cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cancelled: {type: boolean, example: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "402":
          description: Billing not active (TRIAL_EXPIRED / DUNNING_SUSPENDED / BILLING_REQUIRED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: No billable subscription or no pending change (NO_PENDING_CHANGE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "502":
          description: Stripe revert failed (STRIPE_ERROR)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /billing/upgrade:
    post:
      x-stability: internal
      summary: Create a Stripe Checkout session for upgrade
      description: |
        Creates a subscription-mode Stripe Checkout Session for an MSP
        upgrading from trial/free to a paid tier; the frontend redirects
        to the returned hosted URL. Deliberately reachable by
        trial-expired tenants (it is how they unblock the account) — no
        billing-active gate. This is OpsMerge platform billing, not
        MSP-to-client invoicing. DENIED to API keys: 403
        `API_KEY_FORBIDDEN`.
      operationId: createUpgradeCheckout
      tags: [Platform Billing]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tier, period]
              properties:
                tier:
                  type: string
                  enum: [ease_in, crack_on]
                period:
                  type: string
                  enum: [monthly, annual]
      responses:
        "200":
          description: Checkout session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url:
                        type: string
                        format: uri
                        description: Hosted Stripe Checkout URL to redirect to
                      session_id: {type: string}
        "400":
          description: Invalid tier or period (BAD_TIER / BAD_PERIOD / BAD_JSON)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: Stripe error creating the checkout session (STRIPE_ERROR)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "503":
          description: Pricing not configured or provider unavailable
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /bug-reports:
    post:
      x-stability: internal
      summary: Submit a bug report
      description: |
        Files an in-app bug report. The payload is enriched server-side
        (user, org, IP, user agent) and forwarded to the configured n8n
        webhook which creates the GitLab issue and returns a reference
        for the success toast. Optional screenshot as a
        `data:image/...;base64,...` URI (PNG/JPEG/GIF/WebP, 5 MiB base64
        cap; whole body capped at 7 MiB). Rate-limited to 5 reports/hour
        per IP. Session-only: API keys receive 403 `API_KEY_FORBIDDEN`.
        503 `BUGREPORT_DISABLED` when no webhook is configured.
      operationId: submitBugReport
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, severity, description]
              properties:
                title: {type: string, maxLength: 200}
                severity:
                  type: string
                  enum: [low, medium, high]
                description: {type: string, maxLength: 5000}
                steps: {type: string, maxLength: 5000}
                route:
                  type: string
                  maxLength: 500
                  description: SPA route the user was on
                viewport: {type: string, maxLength: 50, example: 2560x1440}
                image_data_uri:
                  type: string
                  description: 'Optional data:image/*;base64 screenshot (5 MiB max)'
      responses:
        "200":
          description: Report filed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      reference:
                        type: string
                        description: Issue reference from the webhook ("submitted" if none returned)
                        example: "1042"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "413":
          description: Payload exceeds the 7 MiB body cap (PAYLOAD_TOO_LARGE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: Webhook forward failed (BUGREPORT_FORWARD_FAILED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "503":
          description: Bug reporting not configured (BUGREPORT_DISABLED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /chat-sessions/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: psa-chat:write
      summary: Get a chat session
      description: |
        Returns the chat session plus its last 200 messages in
        chronological order. Cross-tenant lookups 404.
      operationId: getChatSession
      tags: [Chat]
      responses:
        "200":
          description: Session and recent messages
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      session:
                        type: object
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          agent_id: {type: string, format: uuid}
                          initiator_user_id: {type: string, format: uuid}
                          user_contact_id: {type: string, format: uuid, nullable: true}
                          user_local_user:
                            type: string
                            nullable: true
                            description: Windows username logged in on the agent at invite time
                          status:
                            type: string
                            enum: [pending, active, declined, closed, timed_out, expired]
                          invited_at: {type: string, format: date-time}
                          accepted_at: {type: string, format: date-time, nullable: true}
                          closed_at: {type: string, format: date-time, nullable: true}
                          expires_at: {type: string, format: date-time}
                          decline_reason: {type: string, nullable: true}
                          properties: {type: object, nullable: true}
                          created_at: {type: string, format: date-time}
                          updated_at: {type: string, format: date-time}
                      messages:
                        type: array
                        description: 'Last 200 messages, oldest first'
                        items: &ChatMessage
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            session_id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            sender_type:
                              type: string
                              enum: [tech, user]
                            sender_user_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Set for tech messages; null for end-user messages
                            body: {type: string}
                            sent_at: {type: string, format: date-time}
        "400":
          description: Invalid session ID
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /chat-sessions/{id}/close:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: psa-chat:write
      summary: Close a chat session
      description: |
        Ends the session from the tech side: dispatches `chat-close` to
        the agent and broadcasts a terminal status event to any open
        streams. 409 `CHAT_TERMINAL` if the session is already in a
        terminal state. Empty request body.
      operationId: closeChatSession
      tags: [Chat]
      responses:
        "204":
          description: Session closed
        "400":
          description: Invalid session ID
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Session already closed (CHAT_TERMINAL)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /chat-sessions/{id}/messages:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: psa-chat:write
      summary: Send a chat message
      description: |
        Appends a tech message to an active session, relays it to the
        agent tray, and broadcasts it on the session's WebSocket stream.
        409 `CHAT_NOT_ACTIVE` when the session is not in the `active`
        state.
      operationId: postChatMessage
      tags: [Chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: {type: string, description: Message text (non-empty)}
      responses:
        "201":
          description: Message stored and dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ChatMessage
        "400":
          description: Invalid session ID, body, or empty message
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Session not active (CHAT_NOT_ACTIVE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /chat-sessions/{id}/stream:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: psa-chat:write
      summary: Stream chat session events (WebSocket)
      description: |
        WebSocket fan-out of live session events to the tech browser.
        NOT Bearer-authenticated — browser WebSocket constructors cannot
        set an Authorization header, so the handshake is:

        1. `POST /auth/ws-ticket` (Bearer-authed) → single-use ticket,
           60-second TTL.
        2. Open the WebSocket with `?ticket=<raw>` on this URL; the
           ticket is redeemed atomically during the upgrade (401 if
           invalid, expired, or already used).
        3. The server upgrades the connection and immediately sends an
           initial status frame, then forwards session events.

        Frames are JSON text messages:
        - `{"type": "status", "status": "active"}` — sent first
          (current status, so no separate GET is needed) and again on
          every status transition (statuses: pending, active, declined,
          closed, timed_out, expired).
        - `{"type": "message", "message": {...}}` — a ChatMessage object
          (either direction).

        The server pings every 30 seconds (browsers auto-pong); the
        connection has a 90-second read deadline and a 60-minute
        absolute cap. The client is not expected to send frames.
      operationId: streamChatSession
      tags: [Chat]
      parameters:
        - name: ticket
          in: query
          required: true
          description: Single-use ticket from POST /auth/ws-ticket
          schema: {type: string}
      responses:
        "101":
          description: Switching Protocols — WebSocket upgrade accepted
        "400":
          description: Invalid session ID
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401":
          description: Ticket invalid, expired, or already used (AUTH_INVALID)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /checks:
    get:
      x-stability: stable
      x-required-scope: checks:read
      summary: List checks across all agents
      description: |
        Tenant-wide paginated check list with agent/site/client context
        joined in. `status=unknown` (or `pending`) matches checks that
        have never run (null last_status). Invalid UUID filters are
        ignored rather than rejected.
      operationId: listChecks
      tags: [Checks]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: check_type
          in: query
          schema:
            type: string
            enum: [cpu, memory, disk, service, ping, http, script, eventlog]
        - name: status
          in: query
          description: pass/warning/error filter on last_status; unknown or pending matches never-run checks
          schema:
            type: string
            enum: [pass, warning, error, unknown, pending]
        - name: agent_id
          in: query
          schema: {type: string, format: uuid}
        - name: client_id
          in: query
          schema: {type: string, format: uuid}
        - name: site_id
          in: query
          schema: {type: string, format: uuid}
        - name: search
          in: query
          description: Case-insensitive substring match on check name or agent hostname
          schema: {type: string}
        - name: sort_by
          in: query
          schema:
            type: string
            enum: [name, agent, agent_hostname, check_type, last_status, last_run, last_value]
            default: name
        - name: sort_dir
          in: query
          schema:
            type: string
            enum: [asc, desc]
            default: asc
      responses:
        "200":
          description: Paginated check list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          allOf:
                            - *Check
                            - type: object
                              properties:
                                agent_hostname: {type: string, nullable: true}
                                client_name: {type: string, nullable: true}
                                site_name: {type: string, nullable: true}
                      pagination: &PaginationBlock
                        type: object
                        properties:
                          page: {type: integer}
                          per_page: {type: integer}
                          total: {type: integer}
                          total_pages: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /checks/summary:
    get:
      x-stability: stable
      x-required-scope: checks:read
      summary: Get check status summary across all agents
      description: |
        Aggregate counts for the dashboard with per-type and per-client
        (top 10 by check count) breakdowns.
      operationId: checkSummary
      tags: [Checks]
      responses:
        "200":
          description: Check counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total: {type: integer}
                      passing: {type: integer}
                      warning: {type: integer}
                      failing: {type: integer}
                      unknown:
                        type: integer
                        description: Checks that have never run (null last_status)
                      by_type:
                        type: array
                        items:
                          type: object
                          properties:
                            check_type: {type: string}
                            total: {type: integer}
                            passing: {type: integer}
                            warning: {type: integer}
                            failing: {type: integer}
                      by_client:
                        type: array
                        items:
                          type: object
                          properties:
                            client_id: {type: string, format: uuid}
                            client_name: {type: string}
                            total: {type: integer}
                            passing: {type: integer}
                            warning: {type: integer}
                            failing: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /checks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: checks:read
      summary: Get check details
      description: Returns the check plus its 10 most recent results.
      operationId: getCheck
      tags: [Checks]
      responses:
        "200":
          description: Check with recent results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - *Check
                      - type: object
                        properties:
                          recent_results:
                            type: array
                            maxItems: 10
                            items: &CheckResult
                              type: object
                              description: One row from the check_results table
                              properties:
                                id: {type: string, format: uuid}
                                org_id: {type: string, format: uuid}
                                check_id: {type: string, format: uuid}
                                agent_id: {type: string, format: uuid}
                                status:
                                  type: string
                                  enum: [pass, warning, error]
                                value: {type: number, nullable: true}
                                message: {type: string, nullable: true}
                                created_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: stable
      x-required-scope: checks:write
      summary: Update a check
      description: |
        Partial update — only fields present in the body are changed.
        check_type cannot be changed. The agent's check list is re-pushed
        over NATS after commit (best-effort).
      operationId: updateCheck
      tags: [Checks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
                config: {type: object}
                warn_threshold: {type: number, nullable: true}
                err_threshold: {type: number, nullable: true}
                check_interval: {type: integer, minimum: 30}
                alert_severity:
                  type: string
                  enum: [info, warning, error]
                fails_before_alert: {type: integer}
                enabled: {type: boolean}
      responses:
        "200":
          description: Check updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Check
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: checks:write
      summary: Delete a check
      description: |
        Deletes the check, auto-resolves any active/acknowledged alerts
        pointing at it, and re-pushes the agent's check list over NATS.
      operationId: deleteCheck
      tags: [Checks]
      responses:
        "204":
          description: Check deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /checks/{id}/results:
    get:
      x-stability: stable
      x-required-scope: checks:read
      summary: Get check result history
      description: |
        Paginated result history, newest first. Invalid `from`/`to`
        timestamps are ignored rather than rejected.
      operationId: checkResults
      tags: [Checks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: status
          in: query
          schema:
            type: string
            enum: [pass, warning, error]
        - name: from
          in: query
          description: RFC3339 lower bound on created_at
          schema: {type: string, format: date-time}
        - name: to
          in: query
          description: RFC3339 upper bound on created_at
          schema: {type: string, format: date-time}
      responses:
        "200":
          description: Paginated check results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *CheckResult
                      pagination: *PaginationBlock
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /checks/{id}/run:
    post:
      x-stability: stable
      x-required-scope: checks:write
      summary: Run a check immediately
      description: |
        Executes the check on the agent synchronously via NATS
        request/reply (35s server-side wait) and returns the agent's raw
        result. The result is also recorded by the normal ingest path.
      operationId: runCheck
      tags: [Checks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Check result from the agent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      check_id: {type: string, format: uuid}
                      agent_id:
                        type: string
                        description: Agent NATS identifier (not the UUID primary key)
                      status:
                        type: string
                        enum: [pass, warning, error]
                      value: {type: number}
                      message: {type: string}
                      timestamp:
                        type: integer
                        format: int64
                        description: Unix seconds
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "504":
          description: Agent did not respond within the request window (AGENT_TIMEOUT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /clients:
    get:
      x-stability: stable
      x-required-scope: clients:read
      summary: List clients
      description: |
        Returns a paginated client list with each client's sites embedded.
        Archived clients (`is_active=false`) are hidden by default; pass
        `include_inactive=true` to include them or `only_inactive=true` to see
        archived rows only. Unlike most list endpoints, `per_page` is honoured
        up to 2000 here so the Clients tree can render in one request.
        List rows omit the billing-detail fields (`billing_email`, addresses,
        `vat_number`) — fetch `GET /clients/{id}` for the full record.
      operationId: listClients
      tags: [Clients]
      parameters:
        - name: page
          in: query
          schema: {type: integer, minimum: 1, default: 1}
        - name: per_page
          in: query
          description: "Items per page; this endpoint accepts up to 2000 (most endpoints cap at 100)"
          schema: {type: integer, minimum: 1, maximum: 2000, default: 50}
        - name: search
          in: query
          description: Case-insensitive substring match on client name
          schema: {type: string}
        - name: include_inactive
          in: query
          description: "true = include archived (is_active=false) clients"
          schema: {type: boolean, default: false}
        - name: only_inactive
          in: query
          description: "true = archived clients only (takes precedence over include_inactive)"
          schema: {type: boolean, default: false}
      responses:
        "200":
          description: Paginated client list with embedded sites
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &Client
                          type: object
                          description: One client row; billing fields populated on detail GET/PUT only
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string, maxLength: 255}
                            sites_count: {type: integer}
                            agent_count: {type: integer}
                            sites:
                              type: array
                              description: Embedded site summaries (list endpoint only; empty array on detail responses)
                              items:
                                type: object
                                properties:
                                  id: {type: string, format: uuid}
                                  org_id: {type: string, format: uuid}
                                  client_id: {type: string, format: uuid}
                                  name: {type: string}
                                  agent_count: {type: integer}
                                  workstation_policy_id: {type: string, format: uuid, nullable: true}
                                  server_policy_id: {type: string, format: uuid, nullable: true}
                                  alert_template_id: {type: string, format: uuid, nullable: true}
                                  created_at: {type: string, format: date-time}
                                  updated_at: {type: string, format: date-time}
                            workstation_policy_id: {type: string, format: uuid, nullable: true}
                            server_policy_id: {type: string, format: uuid, nullable: true}
                            alert_template_id: {type: string, format: uuid, nullable: true}
                            billing_email: {type: string, description: Detail responses only; omitted when empty}
                            billing_address: &ClientAddress
                              type: object
                              nullable: true
                              description: Address blob (detail responses only); omitted when empty
                              properties:
                                line1: {type: string, maxLength: 100}
                                line2: {type: string, maxLength: 100}
                                city: {type: string, maxLength: 100}
                                region: {type: string, maxLength: 100}
                                postal_code: {type: string, maxLength: 20}
                                country: {type: string, maxLength: 2, description: ISO-3166-1 alpha-2}
                            service_address: *ClientAddress
                            service_address_same_as_billing: {type: boolean}
                            delivery_address: *ClientAddress
                            delivery_address_same_as_billing: {type: boolean}
                            vat_number: {type: string, maxLength: 64, description: Detail responses only; omitted when empty}
                            telecom_cdr_appendix_enabled:
                              type: boolean
                              description: Whether telecom invoices append the itemised call-detail pages
                            external_id: {type: string, description: Accounting-integration link (omitted when unlinked)}
                            external_system: {type: string, description: "e.g. quickbooks_online (omitted when unlinked)"}
                            is_active:
                              type: boolean
                              description: "false = archived; archived clients are hidden from pickers and default lists"
                            qbo_push_pending: {type: boolean, description: A local edit is awaiting push to QuickBooks Online}
                            qbo_push_last_attempt_at: {type: string, format: date-time, nullable: true}
                            qbo_push_last_error: {type: string, description: Omitted when empty}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      x-stability: stable
      x-required-scope: clients:write
      summary: Create a client
      description: |
        Creates a client under the authenticated tenant. Name must be unique
        within the org. Free-tier tenants are capped (402 FREE_TIER_CAP_EXCEEDED
        on the cap+1th client). Billing details are set via PUT after creation.
      operationId: createClient
      tags: [Clients]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string, maxLength: 255}
                telecom_cdr_appendix_enabled:
                  type: boolean
                  description: Optional; omitting leaves the DB default (true)
      responses:
        "201":
          description: Client created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Client
        "400":
          $ref: "#/components/responses/ValidationError"
        "402":
          description: "Free-tier client cap reached (FREE_TIER_CAP_EXCEEDED; error carries cap_name, current_count, cap_limit, upgrade_url)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Client name already exists (DUPLICATE_NAME)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /clients/{clientID}/email-domains:
    parameters:
      - name: clientID
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: clients:read
      summary: List a client's email domains
      description: Domains used to auto-route inbound ticket email to this client.
      operationId: listClientEmailDomains
      tags: [Email Gateway]
      responses:
        "200":
          description: Domain list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &ClientEmailDomain
                      type: object
                      properties:
                        id: {type: string}
                        client_id: {type: string}
                        domain: {type: string}
                        created_at: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      x-required-scope: clients:write
      summary: Add an email domain to a client
      operationId: addClientEmailDomain
      tags: [Email Gateway]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: {type: string}
      responses:
        "201":
          description: Domain added
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ClientEmailDomain
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /clients/{clientID}/email-domains/{id}:
    parameters:
      - name: clientID
        in: path
        required: true
        schema: {type: string, format: uuid}
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    delete:
      x-stability: internal
      x-required-scope: clients:write
      summary: Remove an email domain from a client
      operationId: removeClientEmailDomain
      tags: [Email Gateway]
      responses:
        "204":
          description: Domain removed
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /clients/{clientID}/licenses:
    get:
      x-stability: stable
      x-required-scope: licenses:read
      summary: List licences assigned to a client
      description: |
        Returns software licences whose `assigned_client_id` matches the
        client, with the same per-row enrichment as the main list
        (assigned_count, usage_status, joined client/site names).
        Response `data` is a bare array — no pagination.
      operationId: listClientLicenses
      tags: [Licenses]
      parameters:
        - name: clientID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Licence list for the client
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &SoftwareLicense
                      type: object
                      description: One tracked software licence with usage enrichment
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        product_name: {type: string}
                        publisher: {type: string}
                        license_key: {type: string}
                        license_type:
                          type: string
                          enum: [perpetual, subscription, volume, oem]
                        quantity: {type: integer}
                        assigned_client_id:
                          type: string
                          format: uuid
                          nullable: true
                        assigned_site_id:
                          type: string
                          format: uuid
                          nullable: true
                        client_name:
                          type: string
                          description: Joined client name ("" when unassigned)
                        site_name:
                          type: string
                          description: Joined site name ("" when unassigned)
                        purchase_date:
                          type: string
                          nullable: true
                          description: YYYY-MM-DD
                        expiry_date:
                          type: string
                          nullable: true
                          description: YYYY-MM-DD
                        cost:
                          type: number
                          nullable: true
                        notes: {type: string}
                        match_pattern:
                          type: string
                          description: Case-insensitive regex matched against software inventory names
                        warn_days:
                          type: array
                          items: {type: integer}
                          description: Days-before-expiry warning thresholds
                        assigned_count:
                          type: integer
                          description: Live count of agent assignments
                        usage_status:
                          type: string
                          enum: [ok, warning, over]
                          description: Derived from assigned_count vs quantity (warning at >=80%)
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "400":
          description: Invalid client ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /clients/{clientID}/managed-updates:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get managed-updates state for a client
      operationId: getClientManagedUpdates
      tags: [Patches]
      parameters:
        - name: clientID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Direct + effective managed-updates view
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &ManagedUpdatesScopeView
                    type: object
                    properties:
                      direct:
                        type: boolean
                        nullable: true
                        description: Override at this scope; null = inherit
                      effective: {type: boolean}
                      source:
                        type: string
                        enum: [organization, client, site, agent]
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Set the client-level managed-updates override
      description: |
        Sets `clients.managed_updates` (nullable - `"managed": null`
        clears the override) and fans out set-update-control to every
        agent under the client's sites.
      operationId: updateClientManagedUpdates
      tags: [Patches]
      parameters:
        - name: clientID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema: *ManagedUpdatesUpdateRequest
      responses: &ManagedUpdatesFanOutResponses
        "200":
          description: Override stored; fan-out started
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      managed: {type: boolean, nullable: true}
                      fan_out: {type: string, example: started}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /clients/{clientID}/patch-policy:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get the patch policy assigned to a client
      operationId: getClientPatchPolicy
      tags: [Patches]
      parameters:
        - name: clientID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Direct + effective patch-policy assignment
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PatchPolicyAssignmentView
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Assign or clear the patch policy for a client
      operationId: assignClientPatchPolicy
      tags: [Patches]
      parameters:
        - name: clientID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema: *PatchPolicyAssignmentRequest
      responses:
        "204":
          description: Assignment updated
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /clients/{clientID}/policy:
    put:
      x-stability: stable
      x-required-scope: policies:write
      summary: Assign policies to a client
      description: |
        Sets the client-level workstation and server policies in one call
        (both columns are always written — omit/null a field to clear that
        assignment), then re-materialises policies for all agents under
        the client asynchronously.
      operationId: assignClientPolicy
      tags: [Policies]
      parameters:
        - name: clientID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema: &HierarchyPolicyAssignment
              type: object
              properties:
                workstation_policy_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Policy applied to workstation-class agents; null clears
                server_policy_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Policy applied to server-class agents; null clears
      responses:
        "200":
          description: Client policy updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /clients/{clientID}/sites:
    parameters:
      - name: clientID
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: sites:read
      summary: List sites under a client
      operationId: listSitesByClient
      tags: [Sites]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated site list, ordered by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &Site
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            client_id: {type: string, format: uuid}
                            name: {type: string, maxLength: 255}
                            client_name:
                              type: string
                              description: "Populated on GET /sites only; omitted elsewhere"
                            agent_count:
                              type: integer
                              description: "Populated in embedded client-list site rows; 0 elsewhere"
                            workstation_policy_id: {type: string, format: uuid, nullable: true}
                            server_policy_id: {type: string, format: uuid, nullable: true}
                            alert_template_id: {type: string, format: uuid, nullable: true}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      x-stability: stable
      x-required-scope: sites:write
      summary: Create a site under a client
      description: Site name must be unique within the client (409 DUPLICATE_NAME).
      operationId: createSite
      tags: [Sites]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string, maxLength: 255}
      responses:
        "201":
          description: Site created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Site
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Site name already exists under this client (DUPLICATE_NAME)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /clients/{client_id}/bitdefender/toggle:
    parameters:
      - name: client_id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: clients:write
      summary: Get the per-client Bitdefender billing toggle
      description: |
        Read uses the same clients-manage permission as write because the
        toggle drives billing exposure. td_enabled reports whether the
        client also has ThreatDown enabled.
      operationId: getClientBitdefenderToggle
      tags: [Bitdefender]
      responses:
        "200":
          description: Toggle state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &ClientBDToggle
                    type: object
                    properties:
                      client_id: {type: string}
                      enabled: {type: boolean}
                      td_enabled: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: clients:write
      summary: Set the per-client Bitdefender billing toggle
      operationId: setClientBitdefenderToggle
      tags: [Bitdefender]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: {type: boolean}
      responses:
        "200":
          description: Updated toggle state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ClientBDToggle
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /clients/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: clients:read
      summary: Get a client by ID
      description: |
        Full client record including billing email, billing/service/delivery
        addresses, VAT number, archive flag, and QuickBooks push state.
      operationId: getClient
      tags: [Clients]
      responses:
        "200":
          description: Client details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Client
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      x-stability: stable
      x-required-scope: clients:write
      summary: Update a client
      description: |
        Partial update — omitted fields are left untouched. `is_active=false`
        archives the client; `true` restores it. An empty body (no updatable
        fields) is rejected with 400. Edits to QuickBooks-mirrored fields on a
        QBO-linked client flag `qbo_push_pending` and enqueue a push.
      operationId: updateClient
      tags: [Clients]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
                billing_email: {type: string, maxLength: 255}
                billing_address: *ClientAddress
                service_address: *ClientAddress
                service_address_same_as_billing: {type: boolean}
                delivery_address: *ClientAddress
                delivery_address_same_as_billing: {type: boolean}
                vat_number: {type: string, maxLength: 64, description: Empty string clears the stored value}
                telecom_cdr_appendix_enabled: {type: boolean}
                is_active: {type: boolean, description: "false archives, true restores"}
      responses:
        "200":
          description: Updated client
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Client
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Client name already exists (DUPLICATE_NAME)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      x-stability: stable
      x-required-scope: clients:write
      summary: Delete a client
      description: |
        Hard delete. Refused with 409 CLIENT_IN_USE when the client is
        referenced by any sites, agents, invoices, contracts, tickets, or
        contacts — the 409 details carry the per-table counts. Archive
        (`PUT {is_active: false}`) is the canonical retire path.
      operationId: deleteClient
      tags: [Clients]
      responses:
        "204":
          description: Client deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: "Client is in use (CLIENT_IN_USE; details = usage counts as in GET /clients/{id}/usage)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /clients/{id}/alert-template:
    put:
      x-stability: stable
      x-required-scope: clients:write
      summary: Assign or clear the client's alert template
      description: |
        Sets `alert_template_id` on the client; `null` clears the override so
        alert resolution falls through to the org default. The assignment
        cascades to sites/agents under this client that have no override of
        their own (resolved by the alert engine at dispatch time).
      operationId: assignClientAlertTemplate
      tags: [Clients]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [alert_template_id]
              properties:
                alert_template_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Template to assign, or null to clear
      responses:
        "200":
          description: Updated client (billing fields omitted on this response)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Client
        "400":
          description: Invalid body, or alert_template_id not found in this tenant (INVALID_ASSIGNMENT)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /clients/{id}/custom-fields:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: internal
      x-required-scope: clients:read
      summary: Get custom field values for a client
      operationId: getClientCustomFields
      tags: [CustomFields]
      responses:
        "200": *CustomFieldValueList
    put:
      x-stability: internal
      x-required-scope: clients:write
      summary: Set custom field values for a client
      operationId: setClientCustomFields
      tags: [CustomFields]
      requestBody: *CustomFieldValuesUpdate
      responses:
        "200": *CustomFieldValuesUpdated
        "400":
          $ref: "#/components/responses/ValidationError"
  /clients/{id}/threatdown:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: clients:write
      summary: Get the client's ThreatDown configuration
      operationId: getClientThreatdown
      tags: [ThreatDown]
      responses:
        "200":
          description: Client ThreatDown state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      client_name: {type: string}
                      enabled: {type: boolean}
                      td_site_id: {type: string, nullable: true}
                      td_account_id: {type: string, nullable: true}
                      endpoint_count: {type: integer}
                      pending_uninstall_count: {type: integer}
                      site_name:
                        type: string
                        description: Present when resolvable from OneView
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: clients:write
      summary: Update the client's ThreatDown configuration
      description: |
        Partial update of enabled/site/account. Flipping enabled off
        triggers auto-uninstall of linked ThreatDown agents.
      operationId: updateClientThreatdown
      tags: [ThreatDown]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: {type: boolean, nullable: true}
                td_site_id: {type: string, nullable: true}
                td_account_id: {type: string, nullable: true}
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /clients/{id}/threatdown/create-site:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: clients:write
      summary: Create a OneView site for the client
      description: |
        Creates the site in ThreatDown OneView, links it to the client and
        optionally assigns an initial subscription.
      operationId: createClientThreatdownSite
      tags: [ThreatDown]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [company_name]
              properties:
                company_name: {type: string}
                firstname: {type: string}
                lastname: {type: string}
                email: {type: string}
                site_end_date: {type: string}
                subscription: &TDSubscriptionRequest
                  type: object
                  required: [product, term_type, term_length, allocations]
                  properties:
                    product: {type: string}
                    machine_type: {type: string, nullable: true}
                    term_type: {type: string}
                    term_length:
                      description: Integer, also accepted as a numeric string
                    allocations:
                      type: object
                      additionalProperties: {type: integer}
                    auto_convert_trial_to_paid: {type: boolean, nullable: true}
      responses:
        "200":
          description: Site created (subscription echoed when assigned)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /clients/{id}/threatdown/match:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: clients:write
      summary: Auto-match the client to a OneView site by name
      description: No request body — matching is by client name.
      operationId: matchClientThreatdown
      tags: [ThreatDown]
      responses:
        "200":
          description: Match result (empty strings when no match found)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      td_site_id: {type: string}
                      td_account_id: {type: string}
                      site_name: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /clients/{id}/threatdown/subscription:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: clients:write
      summary: Get the client's OneView site subscription
      operationId: getClientThreatdownSubscription
      tags: [ThreatDown]
      responses:
        "200":
          description: Subscription (proxied OneView record, null when none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      subscription:
                        type: object
                        nullable: true
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
    post:
      x-stability: internal
      x-required-scope: clients:write
      summary: Assign an initial subscription to the client's site
      operationId: createClientThreatdownSubscription
      tags: [ThreatDown]
      requestBody:
        required: true
        content:
          application/json:
            schema: *TDSubscriptionRequest
      responses:
        "200":
          description: Assigned subscription
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      subscription:
                        type: object
                        nullable: true
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
    put:
      x-stability: internal
      x-required-scope: clients:write
      summary: Replace the full subscription list on the client's site
      description: Body is an array of subscription requests; empty array removes all.
      operationId: updateClientThreatdownSubscriptions
      tags: [ThreatDown]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items: *TDSubscriptionRequest
      responses:
        "200":
          description: Updated subscription list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      subscriptions:
                        type: array
                        items: {type: object}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /clients/{id}/threatdown/subscription/extend-trial:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: clients:write
      summary: Extend the trial subscription on the client's site
      description: No request body.
      operationId: extendClientThreatdownTrial
      tags: [ThreatDown]
      responses:
        "200":
          description: Trial extended
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      subscription:
                        type: object
                        nullable: true
                      extended: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /clients/{id}/usage:
    get:
      x-stability: stable
      x-required-scope: clients:read
      summary: Get reference counts for a client
      description: |
        Returns the per-table reference counts used to pre-check deletion.
        Any non-zero counter means DELETE will refuse with 409 CLIENT_IN_USE
        and archiving is the supported retire path.
      operationId: getClientUsage
      tags: [Clients]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Reference counts (zeros when the client has no references)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      site_count: {type: integer}
                      agent_count: {type: integer}
                      invoice_count: {type: integer}
                      contract_count: {type: integer}
                      ticket_count: {type: integer}
                      contact_count: {type: integer}
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /compliance/agents/{agentID}/score:
    parameters:
      - name: agentID
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Get the compliance score for one agent
      description: |
        Weighted 0-100 score — check pass rate 40%, policy adherence 30%,
        patch success rate (90 days) 30%. Computed on demand.
      operationId: getAgentComplianceScore
      tags: [Compliance]
      responses:
        "200":
          description: Agent score
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &AgentComplianceScore
                    type: object
                    properties:
                      agent_id: {type: string, format: uuid}
                      hostname: {type: string}
                      score: {type: number, description: 0-100}
                      check_rate: {type: number, description: 0.0-1.0}
                      policy_rate: {type: number, description: 0.0 or 1.0}
                      patch_rate: {type: number, description: 0.0-1.0}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /compliance/clients/{clientID}/score:
    parameters:
      - name: clientID
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Get the aggregate compliance score for a client
      operationId: getClientComplianceScore
      tags: [Compliance]
      parameters:
        - name: detail
          in: query
          description: true includes the per-agent breakdown
          schema: {type: boolean}
      responses:
        "200":
          description: Client score
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &ClientComplianceScore
                    type: object
                    properties:
                      client_id: {type: string, format: uuid}
                      client_name: {type: string}
                      average_score: {type: number, description: 0-100}
                      agent_count: {type: integer}
                      agents:
                        type: array
                        description: Only with detail=true
                        items: *AgentComplianceScore
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /compliance/score:
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Get compliance scores for every client in the org
      operationId: getOrgComplianceScore
      tags: [Compliance]
      responses:
        "200":
          description: Per-client scores
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: *ClientComplianceScore
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /csat/{token}:
    get:
      x-stability: beta
      summary: Load a CSAT survey landing page
      description: |
        Public, unauthenticated survey landing-page payload. The
        unguessable single-use token (24 random bytes, base64url, embedded
        in the survey email) is the sole authorisation — there is no
        bearer token and no CSRF token beyond it.

        Also mounted at the ROOT level as `GET /csat/{token}` (outside
        `/api/v1`) so the email CTA link works for humans landing
        directly on it; this documents the `/api/v1` form, which is what
        the frontend calls. Both mounts share one rate-limit bucket
        (10 requests/min per IP).

        State machine:
        * Unanswered + unexpired → `200` with the ticket subject, status
          and client name so the survey page can render context.
        * Already answered → `200` with `already_responded: true` plus the
          previously-submitted `rating`/`comment`; the ticket subject,
          status and client name are blanked (returned as empty strings)
          so a leaked/forwarded URL stops exposing ticket content.
        * Expired and unanswered → `410`.
        * Unknown token → `404`.

        White-label branding (migration 000304) is returned in every
        `200` state: `business_name` is the MSP's trading name for the
        "<name> Support" sign-off (falls back to the organisation name
        when unset) and `google_review_url` drives the 5-star thank-you
        page review CTA — empty string when the tenant has not configured
        one, which hides the CTA entirely.
      operationId: getCsatSurvey
      tags: [CSAT]
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: Single-use survey token from the CSAT email link
          schema: {type: string}
      responses:
        "200":
          description: Survey landing payload (pending or already answered)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ticket_id:
                        type: string
                        format: uuid
                      subject:
                        type: string
                        description: Ticket subject; empty string once answered or expired
                      status:
                        type: string
                        description: Ticket status; empty string once answered or expired
                      client_name:
                        type: string
                        description: End-client name; empty string once answered or expired
                      already_responded:
                        type: boolean
                      expired:
                        type: boolean
                      expires_at:
                        type: string
                        format: date-time
                      rating:
                        type: integer
                        nullable: true
                        minimum: 1
                        maximum: 5
                        description: Present only when already_responded is true
                      comment:
                        type: string
                        nullable: true
                        description: Present only when already_responded is true
                      business_name:
                        type: string
                        description: MSP trading name for the survey sign-off; falls back to the organisation name when unset
                      google_review_url:
                        type: string
                        description: Google review link for the 5-star thank-you CTA; empty string hides the CTA
              examples:
                pending:
                  summary: Unanswered survey
                  value:
                    data:
                      ticket_id: "0d9f9d6e-3a44-4d6b-9a1f-7c2c2f6f1a01"
                      subject: "Printer offline in accounts"
                      status: "closed"
                      client_name: "Greendale Wealth Management"
                      already_responded: false
                      expired: false
                      expires_at: "2026-06-21T09:30:00Z"
                      business_name: "Acme Managed Services"
                      google_review_url: "https://g.page/r/CdExample/review"
                already_answered:
                  summary: "Answered survey — ticket detail blanked, prior rating echoed"
                  value:
                    data:
                      ticket_id: "0d9f9d6e-3a44-4d6b-9a1f-7c2c2f6f1a01"
                      subject: ""
                      status: ""
                      client_name: ""
                      already_responded: true
                      expired: false
                      expires_at: "2026-06-21T09:30:00Z"
                      rating: 5
                      comment: "Sorted in ten minutes, brilliant."
                      business_name: "Acme Managed Services"
                      google_review_url: ""
        "404":
          $ref: '#/components/responses/NotFound'
        "410":
          description: Survey token expired before a response was submitted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
    post:
      x-stability: beta
      summary: Submit a CSAT survey response
      description: |
        Public, unauthenticated survey submission. The single-use token is
        the authorisation; submission is enforced as single-use server-side
        — the first valid submit records the rating and every subsequent
        attempt returns `409 {accepted: false, reason: already_submitted}`
        (the frontend renders the thank-you page rather than an error).

        Also mounted at the ROOT level as `POST /csat/{token}` (outside
        `/api/v1`); this documents the `/api/v1` form. Rate-limited to
        10 requests/min per IP across both mounts.

        Validation: `rating` must be 1–5; `comment` is optional and capped
        at 2000 characters. Successful submits are audit-logged
        (`csat.responded`, with only the last 8 token characters stored).
        A 5-star rating drives the thank-you page's Google review CTA when
        the tenant has set `google_review_url`.
      operationId: submitCsatSurvey
      tags: [CSAT]
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: Single-use survey token from the CSAT email link
          schema: {type: string}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rating]
              properties:
                rating:
                  type: integer
                  minimum: 1
                  maximum: 5
                comment:
                  type: string
                  maxLength: 2000
                  description: Optional free-text feedback
            example:
              rating: 5
              comment: "Sorted in ten minutes, brilliant."
      responses:
        "200":
          description: Response recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      accepted:
                        type: boolean
                      responded_at:
                        type: string
                        format: date-time
                        nullable: true
              example:
                data:
                  accepted: true
                  responded_at: "2026-06-10T14:21:09Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Survey was already submitted (single-use token spent)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      accepted:
                        type: boolean
                      reason:
                        type: string
              example:
                data:
                  accepted: false
                  reason: "already_submitted"
        "410":
          description: Survey token expired before a response was submitted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /custom-fields:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: List custom field definitions
      operationId: listCustomFields
      tags: [CustomFields]
      responses:
        "200":
          description: Field definitions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &CustomFieldDef
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        name:
                          type: string
                        field_type:
                          type: string
                          enum: [text, number, checkbox, dropdown]
                        applies_to:
                          type: string
                          enum: [agent, client, site]
                        options:
                          type: array
                          items:
                            type: string
                          description: For dropdown fields
                        required:
                          type: boolean
                        default_value:
                          type: string
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Create a custom field definition
      operationId: createCustomField
      tags: [CustomFields]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, field_type, applies_to]
              properties:
                name:
                  type: string
                field_type:
                  type: string
                  enum: [text, number, checkbox, dropdown]
                applies_to:
                  type: string
                  enum: [agent, client, site]
                options:
                  type: array
                  items:
                    type: string
                  description: For dropdown fields
                required:
                  type: boolean
                default_value:
                  type: string
      responses:
        "201":
          description: Field created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *CustomFieldDef
        "400":
          $ref: "#/components/responses/ValidationError"
  /custom-fields/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update a custom field definition
      description: |
        `name` is required on every update; `field_type`, `options`,
        `required`, and `default_value` are applied when present.
        `applies_to` cannot be changed.
      operationId: updateCustomField
      tags: [CustomFields]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                field_type:
                  type: string
                  enum: [text, number, checkbox, dropdown]
                options:
                  type: array
                  items:
                    type: string
                required:
                  type: boolean
                default_value:
                  type: string
      responses:
        "200":
          description: Field updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *CustomFieldDef
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      x-stability: internal
      x-required-scope: settings:write
      summary: Delete a custom field definition
      operationId: deleteCustomField
      tags: [CustomFields]
      responses:
        "204":
          description: Field deleted
        "404":
          $ref: "#/components/responses/NotFound"
  /dashboard/summary:
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: Get the dashboard summary
      description: |
        Aggregated counts for the dashboard page: agents by computed
        status (using the org's disconnected/offline thresholds),
        servers-only breakdown (standard_server / critical_server /
        domain_controller), unresolved alerts by severity, check and
        task pass/fail counts, and unacknowledged ThreatDown detections.
      operationId: getDashboardSummary
      tags: [Account]
      responses:
        "200":
          description: Aggregated dashboard counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total_agents: {type: integer}
                      agents_online: {type: integer}
                      agents_disconnected: {type: integer}
                      agents_offline: {type: integer}
                      servers_total: {type: integer}
                      servers_online: {type: integer}
                      servers_disconnected: {type: integer}
                      servers_offline: {type: integer}
                      total_alerts:
                        type: integer
                        description: Active (unresolved) alerts
                      alerts_error: {type: integer}
                      alerts_warning: {type: integer}
                      alerts_info: {type: integer}
                      checks_passing: {type: integer}
                      checks_failing: {type: integer}
                      tasks_passing: {type: integer}
                      tasks_failing: {type: integer}
                      threatdown_unacknowledged: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /device-counts:
    get:
      x-stability: internal
      x-required-scope: network-devices:read
      summary: Get combined agent and network device counts
      description: Counts grouped by client and platform/device type.
      operationId: getDeviceCounts
      tags: [Network Devices]
      parameters:
        - name: client_id
          in: query
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Count summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      agents:
                        type: array
                        items: &DeviceCountGroup
                          type: object
                          properties:
                            client_name: {type: string}
                            type: {type: string}
                            count: {type: integer}
                      network_devices:
                        type: array
                        items: *DeviceCountGroup
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /documents:
    get:
      x-stability: stable
      x-required-scope: documents:read
      summary: List documents
      description: |
        Lists documents in the tenant library. `target_type` and `target_id`
        must be supplied **together** to scope to documents attached to one
        entity, or both omitted for the full library. Filters combine with
        AND. `mime_type` accepts an exact value or a trailing-`*` prefix
        (e.g. `image/*`). Pass `include=associations` to hydrate each
        document's `associations` array.

        The deprecated `documents` response key mirrors `items` for one
        release of backwards compatibility — use `items`.

        Requires an MSP staff user in addition to the `documents:read` scope.
      operationId: listDocuments
      tags: [Documents]
      parameters:
        - name: target_type
          in: query
          description: Scope to documents attached to this entity type (requires target_id)
          schema:
            type: string
            enum: [ticket, client, project, kb_article]
        - name: target_id
          in: query
          description: Scope to documents attached to this entity (requires target_type)
          schema:
            type: string
            format: uuid
        - name: client_id
          in: query
          description: Restrict to documents linked to this client
          schema:
            type: string
            format: uuid
        - name: q
          in: query
          description: Substring filter against filename (wildcards are matched literally)
          schema:
            type: string
        - name: doc_type
          in: query
          description: Filter by document kind
          schema:
            type: string
            enum: [file, article]
        - name: mime_type
          in: query
          description: Exact MIME type or trailing-* prefix (e.g. image/*)
          schema:
            type: string
        - name: uploader_kind
          in: query
          description: Who uploaded — MSP staff (user) or portal contact (contact)
          schema:
            type: string
            enum: [user, contact]
        - name: include
          in: query
          description: Comma-separated projections. Only `associations` is supported; unknown tokens return 400.
          schema:
            type: string
            enum: [associations]
        - name: limit
          in: query
          description: Maximum rows (default 50, max 200)
          schema:
            type: integer
            default: 50
            maximum: 200
        - name: offset
          in: query
          description: Rows to skip
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: Document list with total count
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            doc_type:
                              type: string
                              enum: [file, article]
                            title:
                              type: string
                              nullable: true
                              description: Set for articles; null for plain file uploads
                            content:
                              type: string
                              nullable: true
                              description: Rich-text body (articles only; omitted when null)
                            filename:
                              type: string
                              nullable: true
                            mime_type:
                              type: string
                              nullable: true
                            size_bytes:
                              type: integer
                              format: int64
                              nullable: true
                            b2_bucket:
                              type: string
                              nullable: true
                            b2_key:
                              type: string
                              nullable: true
                            uploaded_by:
                              type: string
                              format: uuid
                            uploader_kind:
                              type: string
                              enum: [user, contact]
                            deleted_at:
                              type: string
                              format: date-time
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
                            associations:
                              type: array
                              description: Present only when `include=associations` is requested
                              items:
                                type: object
                                properties:
                                  target_type:
                                    type: string
                                    enum: [ticket, client, project, kb_article, site, agent, document]
                                  target_id:
                                    type: string
                                    format: uuid
                      documents:
                        type: array
                        deprecated: true
                        description: Same array as `items` (one-release backwards-compat shim)
                        items:
                          type: object
                      total:
                        type: integer
                        description: Total matching rows (ignores limit/offset)
              example:
                data:
                  items:
                    - id: "5a6b7c8d-9e0f-4a1b-8c2d-334455667788"
                      org_id: "0b8d2c44-1234-4e5f-9a6b-77ccdd88ee99"
                      doc_type: file
                      title: null
                      filename: "firewall-config.pdf"
                      mime_type: "application/pdf"
                      size_bytes: 482133
                      b2_bucket: "tenant-files"
                      b2_key: "acme-msp/uploads/5a6b7c8d-9e0f-4a1b-8c2d-334455667788/firewall-config.pdf"
                      uploaded_by: "7a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                      uploader_kind: user
                      deleted_at: null
                      created_at: "2026-06-01T11:20:00Z"
                      updated_at: "2026-06-01T11:20:00Z"
                      associations:
                        - target_type: client
                          target_id: "11223344-5566-4788-99aa-bbccddeeff00"
                  documents: []
                  total: 1
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /documents/articles:
    post:
      x-stability: stable
      x-required-scope: documents:write
      summary: Create a rich-text document article
      description: |
        Creates a rich-text document (`doc_type: article`) with HTML content
        and optional associations to other entities. Distinct from the
        Markdown knowledge base (`/kb/articles`) — these are library
        documents authored in the WYSIWYG editor. Max body size 1 MB.

        Requires an MSP staff user in addition to the `documents:write` scope.
      operationId: createDocumentArticle
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                content:
                  type: string
                  description: Rich-text (HTML) body
                associations:
                  type: array
                  items:
                    type: object
                    required: [target_type, target_id]
                    properties:
                      target_type:
                        type: string
                        enum: [ticket, client, project, kb_article, site, agent, document]
                      target_id:
                        type: string
                        format: uuid
            example:
              title: "Acme Corp onboarding runbook"
              content: "<h2>Day 1</h2><p>Provision M365 accounts...</p>"
              associations:
                - target_type: client
                  target_id: "11223344-5566-4788-99aa-bbccddeeff00"
      responses:
        "201":
          description: Article created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      doc_type:
                        type: string
                        enum: [article]
                      title:
                        type: string
              example:
                data:
                  id: "6b7c8d9e-0f1a-4b2c-8d3e-445566778899"
                  doc_type: article
                  title: "Acme Corp onboarding runbook"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /documents/articles/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Document ID (must be doc_type article)
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: documents:read
      summary: Get a rich-text document article
      description: |
        Returns the full article with content, its associations, and any file
        documents attached to it (associations with `target_type: document`
        pointing at this article). Returns 400 if the document exists but is
        not an article, and 410 if it has been deleted.

        Requires an MSP staff user in addition to the `documents:read` scope.
      operationId: getDocumentArticle
      tags: [Documents]
      responses:
        "200":
          description: Article with associations and attachments
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      doc_type:
                        type: string
                        enum: [article]
                      title:
                        type: string
                        nullable: true
                      content:
                        type: string
                        nullable: true
                        description: Rich-text (HTML) body
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
                      associations:
                        type: array
                        items:
                          type: object
                          properties:
                            target_type:
                              type: string
                              enum: [ticket, client, project, kb_article, site, agent, document]
                            target_id:
                              type: string
                              format: uuid
                      attachments:
                        type: array
                        description: File documents attached to this article
                        items:
                          type: object
                          description: Document object (same shape as GET /documents items)
              example:
                data:
                  id: "6b7c8d9e-0f1a-4b2c-8d3e-445566778899"
                  doc_type: article
                  title: "Acme Corp onboarding runbook"
                  content: "<h2>Day 1</h2><p>Provision M365 accounts...</p>"
                  created_at: "2026-06-03T09:00:00Z"
                  updated_at: "2026-06-05T16:45:00Z"
                  associations:
                    - target_type: client
                      target_id: "11223344-5566-4788-99aa-bbccddeeff00"
                  attachments: []
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "410":
          description: Article has been deleted (GONE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
    put:
      x-stability: stable
      x-required-scope: documents:write
      summary: Update a rich-text document article
      description: |
        Updates title and content, and **replaces** the full association set
        (an empty or omitted `associations` array clears all associations).

        Requires an MSP staff user in addition to the `documents:write` scope.
      operationId: updateDocumentArticle
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                content:
                  type: string
                  description: Rich-text (HTML) body
                associations:
                  type: array
                  description: Replaces all existing associations
                  items:
                    type: object
                    required: [target_type, target_id]
                    properties:
                      target_type:
                        type: string
                        enum: [ticket, client, project, kb_article, site, agent, document]
                      target_id:
                        type: string
                        format: uuid
            example:
              title: "Acme Corp onboarding runbook v2"
              content: "<h2>Day 1</h2><p>Provision M365 accounts and Entra groups...</p>"
              associations:
                - target_type: client
                  target_id: "11223344-5566-4788-99aa-bbccddeeff00"
      responses:
        "200":
          description: Article updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      doc_type:
                        type: string
                        enum: [article]
                      title:
                        type: string
              example:
                data:
                  id: "6b7c8d9e-0f1a-4b2c-8d3e-445566778899"
                  doc_type: article
                  title: "Acme Corp onboarding runbook v2"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /documents/confirm-upload:
    post:
      x-stability: stable
      x-required-scope: documents:write
      summary: Confirm a completed upload
      description: |
        Step 3 of the upload flow (after `POST /documents/presigned-upload`
        and the direct PUT to the returned `upload_url`). Verifies the object
        exists in storage (HeadObject), re-validates the stored MIME type
        against the whitelist, then creates the document row.

        `target_type` and `target_id` must be supplied **together** (creates
        a document association) or **omitted together** (library-only
        upload); exactly one of the pair is a 400.

        Requires an MSP staff user in addition to the `documents:write` scope.
      operationId: confirmDocumentUpload
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document_id, b2_key, b2_bucket, filename, mime_type, size_bytes]
              properties:
                document_id:
                  type: string
                  format: uuid
                  description: The document_id returned by presigned-upload
                b2_key:
                  type: string
                b2_bucket:
                  type: string
                filename:
                  type: string
                mime_type:
                  type: string
                size_bytes:
                  type: integer
                  format: int64
                target_type:
                  type: string
                  enum: [ticket, client, project, kb_article, site, agent, document]
                target_id:
                  type: string
                  format: uuid
            example:
              document_id: "5a6b7c8d-9e0f-4a1b-8c2d-334455667788"
              b2_key: "acme-msp/uploads/5a6b7c8d-9e0f-4a1b-8c2d-334455667788/firewall-config.pdf"
              b2_bucket: "tenant-files"
              filename: "firewall-config.pdf"
              mime_type: "application/pdf"
              size_bytes: 482133
              target_type: client
              target_id: "11223344-5566-4788-99aa-bbccddeeff00"
      responses:
        "201":
          description: Document record created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      filename:
                        type: string
                      mime_type:
                        type: string
                      size_bytes:
                        type: integer
                        format: int64
                      b2_bucket:
                        type: string
                      b2_key:
                        type: string
                      uploader_kind:
                        type: string
                        enum: [user]
              example:
                data:
                  id: "5a6b7c8d-9e0f-4a1b-8c2d-334455667788"
                  filename: "firewall-config.pdf"
                  mime_type: "application/pdf"
                  size_bytes: 482133
                  b2_bucket: "tenant-files"
                  b2_key: "acme-msp/uploads/5a6b7c8d-9e0f-4a1b-8c2d-334455667788/firewall-config.pdf"
                  uploader_kind: user
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "415":
          description: Claimed or stored MIME type not on the whitelist (MIME_NOT_ALLOWED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: Object not found in storage — the PUT step failed or used the wrong key (OBJECT_NOT_FOUND)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
        "503":
          description: Object storage not configured on this cell (STORAGE_UNCONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /documents/presign-inline-image:
    post:
      x-stability: stable
      x-required-scope: documents:write
      summary: Request a presigned upload URL for an inline image
      description: |
        Variant of `presigned-upload` for WYSIWYG editor inline images. Only
        image MIME types (`image/jpeg`, `image/png`, `image/gif`,
        `image/webp`) are accepted and the size limit is 10 MB. Returns a
        presigned PUT URL (valid 15 minutes); no DB row is created — after
        the PUT succeeds the client must call `POST /documents/confirm-upload`
        to create the document row, exactly as in the standard
        presign → PUT → confirm flow. Render the image afterwards via
        `GET /documents/{id}/raw`.

        Requires an MSP staff user in addition to the `documents:write` scope.
      operationId: presignInlineImageUpload
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, mime_type, size_bytes]
              properties:
                filename:
                  type: string
                mime_type:
                  type: string
                  enum: [image/jpeg, image/png, image/gif, image/webp]
                size_bytes:
                  type: integer
                  format: int64
                  description: Max 10485760 (10 MB)
            example:
              filename: "network-diagram.png"
              mime_type: "image/png"
              size_bytes: 204800
      responses:
        "200":
          description: Presigned PUT URL for the image
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      upload_url:
                        type: string
                      b2_key:
                        type: string
                      b2_bucket:
                        type: string
              example:
                data:
                  upload_url: "https://s3.eu-central-003.backblazeb2.com/tenant-files/acme-msp/inline-images/8f9e0d1c-2b3a-4c5d-9876-554433221100/network-diagram.png?X-Amz-Expires=900&..."
                  b2_key: "acme-msp/inline-images/8f9e0d1c-2b3a-4c5d-9876-554433221100/network-diagram.png"
                  b2_bucket: "tenant-files"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "413":
          description: Image too large (FILE_TOO_LARGE, max 10 MB) or storage quota exceeded (QUOTA_EXCEEDED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "415":
          description: Not an allowed image MIME type (MIME_NOT_ALLOWED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
        "503":
          description: Object storage not configured on this cell (STORAGE_UNCONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /documents/presigned-upload:
    post:
      x-stability: stable
      x-required-scope: documents:write
      summary: Request a presigned upload URL
      description: |
        Step 1 of the three-step upload flow:

        1. `POST /documents/presigned-upload` — validates MIME type (server
           whitelist), size (max 100 MB), and storage quota, then returns a
           presigned PUT URL (valid 15 minutes) plus a reserved `document_id`
           and `b2_key`.
        2. `PUT <upload_url>` — the client uploads the file bytes **directly
           to object storage** (the API never proxies file content). Set the
           `Content-Type` header to the declared `mime_type`.
        3. `POST /documents/confirm-upload` — echoes back the identifiers to
           create the document record.

        No DB row is created by this step — an abandoned presign costs
        nothing. `target_type`/`target_id` are optional here; the
        both-or-neither rule is enforced at confirm time.

        Storage quota is tier-based: 1 GB (rmm), 10 GB (psa), 50 GB (bundle).

        Requires an MSP staff user in addition to the `documents:write` scope.
      operationId: presignDocumentUpload
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, mime_type, size_bytes]
              properties:
                filename:
                  type: string
                mime_type:
                  type: string
                  description: Must be on the server whitelist (images, PDF, text/CSV, Office docs, zip)
                size_bytes:
                  type: integer
                  format: int64
                  description: Declared file size (max 104857600 = 100 MB)
                target_type:
                  type: string
                  enum: [ticket, client, project, kb_article, site, agent, document]
                  description: Optional intended attachment target (validated here, persisted at confirm)
                target_id:
                  type: string
                  format: uuid
            example:
              filename: "firewall-config.pdf"
              mime_type: "application/pdf"
              size_bytes: 482133
              target_type: client
              target_id: "11223344-5566-4788-99aa-bbccddeeff00"
      responses:
        "200":
          description: Presigned PUT URL and reserved identifiers
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      upload_url:
                        type: string
                        description: Presigned PUT URL, valid for 15 minutes
                      document_id:
                        type: string
                        format: uuid
                        description: Reserved ID — echo back in confirm-upload
                      b2_key:
                        type: string
                      b2_bucket:
                        type: string
              example:
                data:
                  upload_url: "https://s3.eu-central-003.backblazeb2.com/tenant-files/acme-msp/uploads/5a6b7c8d-9e0f-4a1b-8c2d-334455667788/firewall-config.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&..."
                  document_id: "5a6b7c8d-9e0f-4a1b-8c2d-334455667788"
                  b2_key: "acme-msp/uploads/5a6b7c8d-9e0f-4a1b-8c2d-334455667788/firewall-config.pdf"
                  b2_bucket: "tenant-files"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "413":
          description: File too large (FILE_TOO_LARGE, max 100 MB) or storage quota exceeded (QUOTA_EXCEEDED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "415":
          description: MIME type not on the server whitelist (MIME_NOT_ALLOWED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
        "503":
          description: Object storage not configured on this cell (STORAGE_UNCONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /documents/quota:
    get:
      x-stability: stable
      x-required-scope: documents:read
      summary: Get document storage quota usage
      description: |
        Returns the tenant's current storage usage against its tier quota:
        1 GB (rmm), 10 GB (psa), 50 GB (bundle).

        Requires an MSP staff user in addition to the `documents:read` scope.
      operationId: getDocumentsQuota
      tags: [Documents]
      responses:
        "200":
          description: Storage usage and quota
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      used_bytes:
                        type: integer
                        format: int64
                      quota_bytes:
                        type: integer
                        format: int64
                      tier:
                        type: string
                        enum: [rmm, psa, bundle]
              example:
                data:
                  used_bytes: 73400320
                  quota_bytes: 53687091200
                  tier: bundle
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /documents/{id}:
    delete:
      x-stability: stable
      x-required-scope: documents:write
      summary: Delete a document
      description: |
        Soft-deletes the document record, then (best-effort) moves the stored
        object to a `deleted/` prefix in object storage. Article documents
        have no stored object, so only the DB soft-delete applies.

        Requires an MSP staff user in addition to the `documents:write` scope.
      operationId: deleteDocument
      tags: [Documents]
      parameters:
        - name: id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Document deleted
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /documents/{id}/associations:
    put:
      x-stability: stable
      x-required-scope: documents:write
      summary: Replace a document's associations
      description: |
        **Replaces** the full association set for any document type. Send an
        empty array to detach the document from everything (library-only).

        Requires an MSP staff user in addition to the `documents:write` scope.
      operationId: setDocumentAssociations
      tags: [Documents]
      parameters:
        - name: id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                associations:
                  type: array
                  items:
                    type: object
                    required: [target_type, target_id]
                    properties:
                      target_type:
                        type: string
                        enum: [ticket, client, project, kb_article, site, agent, document]
                      target_id:
                        type: string
                        format: uuid
            example:
              associations:
                - target_type: ticket
                  target_id: "99887766-5544-4332-2110-ffeeddccbbaa"
                - target_type: client
                  target_id: "11223344-5566-4788-99aa-bbccddeeff00"
      responses:
        "204":
          description: Associations replaced
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /documents/{id}/download:
    get:
      x-stability: stable
      x-required-scope: documents:read
      summary: Get a download URL for a document
      description: |
        Returns a presigned GET URL valid for 1 hour. The client fetches the
        file bytes directly from object storage. Returns 410 for deleted
        documents and 422 for documents with no stored file (e.g. articles).

        Requires an MSP staff user in addition to the `documents:read` scope.
      operationId: getDocumentDownloadUrl
      tags: [Documents]
      parameters:
        - name: id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Presigned download URL (valid 1 hour)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url:
                        type: string
              example:
                data:
                  url: "https://s3.eu-central-003.backblazeb2.com/tenant-files/acme-msp/uploads/5a6b7c8d-9e0f-4a1b-8c2d-334455667788/firewall-config.pdf?X-Amz-Expires=3600&..."
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "410":
          description: Document has been deleted (GONE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: Document has no stored file, e.g. it is an article (NO_FILE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
        "503":
          description: Object storage not configured on this cell (STORAGE_UNCONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /documents/{id}/raw:
    get:
      x-stability: stable
      x-required-scope: documents:read
      summary: Redirect to the raw document content
      description: |
        Responds with a 302 redirect to a presigned GET URL (valid 1 hour).
        Intended for direct embedding (e.g. `<img src>` for inline images) —
        the browser follows the redirect to render the content. Returns 410
        for deleted documents and 422 for documents with no stored file.

        Requires an MSP staff user in addition to the `documents:read` scope.
      operationId: getDocumentRaw
      tags: [Documents]
      parameters:
        - name: id
          in: path
          required: true
          description: Document ID
          schema:
            type: string
            format: uuid
      responses:
        "302":
          description: Redirect to a presigned object-storage URL
          headers:
            Location:
              description: Presigned GET URL, valid for 1 hour
              schema:
                type: string
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "410":
          description: Document has been deleted (GONE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: Document has no stored file (NO_FILE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
        "503":
          description: Object storage not configured on this cell (STORAGE_UNCONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /domain-monitors:
    get:
      x-stability: stable
      x-required-scope: domain-monitors:read
      summary: List domain monitors
      description: |
        Paginated list of domain monitors for the organisation, ordered by
        domain name. Filterable by company, last check status, and a
        case-insensitive substring match on the domain.
      operationId: listDomainMonitors
      tags: [Domain Monitoring]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - name: page_size
          in: query
          description: Items per page (max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: company_id
          in: query
          description: Only monitors linked to this client
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          description: Only monitors whose last overall check had this status
          schema:
            type: string
            enum: [ok, warning, error]
        - name: search
          in: query
          description: Case-insensitive substring match on the domain
          schema:
            type: string
      responses:
        "200":
          description: Paginated domain monitor list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            company_id:
                              type: string
                              format: uuid
                              nullable: true
                            company_name:
                              type: string
                              nullable: true
                            domain:
                              type: string
                            check_interval:
                              type: integer
                              description: Seconds between scheduled check runs
                            paused:
                              type: boolean
                            ssl_enabled:
                              type: boolean
                            dns_enabled:
                              type: boolean
                            whois_enabled:
                              type: boolean
                            dns_security_enabled:
                              type: boolean
                            last_check_at:
                              type: string
                              format: date-time
                              nullable: true
                            last_status:
                              type: string
                              enum: [ok, warning, error]
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                      pagination:
                        $ref: '#/components/schemas/PaginationMeta'
              example:
                data:
                  items:
                    - id: "7f3c2a10-9b4e-4c6d-8a21-5e9f0d3b6c44"
                      company_id: "2b8e6f1a-3d5c-47e9-9a0b-1c2d3e4f5a6b"
                      company_name: "Acme Managed Services"
                      domain: "acmemanaged.example"
                      check_interval: 300
                      paused: false
                      ssl_enabled: true
                      dns_enabled: true
                      whois_enabled: true
                      dns_security_enabled: true
                      last_check_at: "2026-06-10T09:15:00Z"
                      last_status: "ok"
                      created_at: "2026-04-18T11:02:33Z"
                  pagination:
                    page: 1
                    per_page: 25
                    total: 1
                    total_pages: 1
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
    post:
      x-stability: stable
      x-required-scope: domain-monitors:write
      summary: Create a domain monitor
      description: |
        Registers a domain for server-side monitoring. The domain must be a
        bare hostname (any `http://`/`https://` scheme and trailing slash are
        stripped before validation) and must be unique within the
        organisation. SSL, DNS and WHOIS checks default to enabled;
        DNS-security (DMARC/SPF/DKIM) defaults to disabled.
      operationId: createDomainMonitor
      tags: [Domain Monitoring]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                company_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Optional client to associate the monitor with
                domain:
                  type: string
                  maxLength: 255
                  description: Bare hostname (no scheme or path)
                check_interval:
                  type: integer
                  minimum: 60
                  default: 300
                  description: Seconds between scheduled check runs
                ssl_enabled:
                  type: boolean
                  default: true
                dns_enabled:
                  type: boolean
                  default: true
                whois_enabled:
                  type: boolean
                  default: true
                dns_security_enabled:
                  type: boolean
                  default: false
                ssl_warn_days:
                  type: integer
                  minimum: 1
                  default: 30
                  description: Days before certificate expiry to raise a warning
                domain_warn_days:
                  type: integer
                  minimum: 1
                  default: 30
                  description: Days before domain registration expiry to raise a warning
                notes:
                  type: string
                  nullable: true
            example:
              company_id: "2b8e6f1a-3d5c-47e9-9a0b-1c2d3e4f5a6b"
              domain: "acmemanaged.example"
              check_interval: 300
              dns_security_enabled: true
              ssl_warn_days: 30
              domain_warn_days: 30
              notes: "Primary company domain — watch SSL expiry closely"
      responses:
        "201":
          description: Domain monitor created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &DomainMonitorSchema
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      org_id:
                        type: string
                        format: uuid
                      company_id:
                        type: string
                        format: uuid
                        nullable: true
                      domain:
                        type: string
                      check_interval:
                        type: integer
                        description: Seconds between scheduled check runs
                      paused:
                        type: boolean
                      ssl_enabled:
                        type: boolean
                      dns_enabled:
                        type: boolean
                      whois_enabled:
                        type: boolean
                      dns_security_enabled:
                        type: boolean
                      ssl_warn_days:
                        type: integer
                      domain_warn_days:
                        type: integer
                      next_check_at:
                        type: string
                        format: date-time
                      last_check_at:
                        type: string
                        format: date-time
                        nullable: true
                      last_status:
                        type: string
                        enum: [ok, warning, error]
                        nullable: true
                      notes:
                        type: string
                        nullable: true
                      created_at:
                        type: string
                        format: date-time
                      updated_at:
                        type: string
                        format: date-time
              example:
                data: &DomainMonitorExample
                  id: "7f3c2a10-9b4e-4c6d-8a21-5e9f0d3b6c44"
                  org_id: "9d8c7b6a-5f4e-3d2c-1b0a-998877665544"
                  company_id: "2b8e6f1a-3d5c-47e9-9a0b-1c2d3e4f5a6b"
                  domain: "acmemanaged.example"
                  check_interval: 300
                  paused: false
                  ssl_enabled: true
                  dns_enabled: true
                  whois_enabled: true
                  dns_security_enabled: true
                  ssl_warn_days: 30
                  domain_warn_days: 30
                  next_check_at: "2026-06-10T09:20:00Z"
                  last_check_at: null
                  last_status: null
                  notes: "Primary company domain — watch SSL expiry closely"
                  created_at: "2026-06-10T09:15:00Z"
                  updated_at: "2026-06-10T09:15:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "409":
          description: Domain is already monitored in this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  message: "domain already monitored"
                  code: "DUPLICATE"
        "429":
          $ref: '#/components/responses/RateLimited'
  /domain-monitors/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: domain-monitors:read
      summary: Get a domain monitor
      description: |
        Full monitor detail including the linked client name and the current
        snapshot data for each check type that has run at least once. The
        `snapshots` map is keyed by check type (`ssl`, `dns`, `whois`,
        `dns_security`); each value is the check-type-specific snapshot
        payload.
      operationId: getDomainMonitor
      tags: [Domain Monitoring]
      responses:
        "200":
          description: Domain monitor detail with current snapshots
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - *DomainMonitorSchema
                      - type: object
                        properties:
                          company_name:
                            type: string
                            nullable: true
                          snapshots:
                            type: object
                            description: Current snapshot data keyed by check type
                            additionalProperties:
                              type: object
              example:
                data:
                  id: "7f3c2a10-9b4e-4c6d-8a21-5e9f0d3b6c44"
                  org_id: "9d8c7b6a-5f4e-3d2c-1b0a-998877665544"
                  company_id: "2b8e6f1a-3d5c-47e9-9a0b-1c2d3e4f5a6b"
                  domain: "acmemanaged.example"
                  check_interval: 300
                  paused: false
                  ssl_enabled: true
                  dns_enabled: true
                  whois_enabled: true
                  dns_security_enabled: true
                  ssl_warn_days: 30
                  domain_warn_days: 30
                  next_check_at: "2026-06-10T09:20:00Z"
                  notes: "Primary company domain — watch SSL expiry closely"
                  created_at: "2026-06-10T09:15:00Z"
                  updated_at: "2026-06-10T09:15:00Z"
                  last_check_at: "2026-06-10T09:20:04Z"
                  last_status: "ok"
                  company_name: "Acme Managed Services"
                  snapshots:
                    ssl:
                      issuer: "Example CA"
                      subject: "acmemanaged.example"
                      not_after: "2026-07-19T23:59:59Z"
                      days_remaining: 39
                    dns:
                      a: ["203.0.113.20"]
                      mx: ["10 mx-eu.opsmerge.cloud."]
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
    put:
      x-stability: stable
      x-required-scope: domain-monitors:write
      summary: Update a domain monitor
      description: |
        Partial update — omitted (or null) fields keep their current values,
        so `company_id` and `notes` cannot be cleared back to null via this
        endpoint. Changing `domain` re-validates it as a bare hostname.
      operationId: updateDomainMonitor
      tags: [Domain Monitoring]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                company_id:
                  type: string
                  format: uuid
                domain:
                  type: string
                  maxLength: 255
                  description: Bare hostname (no scheme or path)
                check_interval:
                  type: integer
                  minimum: 60
                paused:
                  type: boolean
                ssl_enabled:
                  type: boolean
                dns_enabled:
                  type: boolean
                whois_enabled:
                  type: boolean
                dns_security_enabled:
                  type: boolean
                ssl_warn_days:
                  type: integer
                  minimum: 1
                domain_warn_days:
                  type: integer
                  minimum: 1
                notes:
                  type: string
            example:
              check_interval: 900
              ssl_warn_days: 45
              notes: "SSL renewal handled by Let's Encrypt auto-renew"
      responses:
        "200":
          description: Updated domain monitor
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *DomainMonitorSchema
              example:
                data:
                  id: "7f3c2a10-9b4e-4c6d-8a21-5e9f0d3b6c44"
                  org_id: "9d8c7b6a-5f4e-3d2c-1b0a-998877665544"
                  company_id: "2b8e6f1a-3d5c-47e9-9a0b-1c2d3e4f5a6b"
                  domain: "acmemanaged.example"
                  check_interval: 900
                  paused: false
                  ssl_enabled: true
                  dns_enabled: true
                  whois_enabled: true
                  dns_security_enabled: true
                  ssl_warn_days: 45
                  domain_warn_days: 30
                  next_check_at: "2026-06-10T09:20:00Z"
                  last_check_at: null
                  last_status: null
                  notes: "SSL renewal handled by Let's Encrypt auto-renew"
                  created_at: "2026-06-10T09:15:00Z"
                  updated_at: "2026-06-10T10:01:12Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
    delete:
      x-stability: stable
      x-required-scope: domain-monitors:write
      summary: Delete a domain monitor
      description: Removes the monitor and cascades to its check history and snapshots.
      operationId: deleteDomainMonitor
      tags: [Domain Monitoring]
      responses:
        "204":
          description: Domain monitor deleted
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /domain-monitors/{id}/dns:
    get:
      x-stability: stable
      x-required-scope: domain-monitors:read
      summary: Get the current DNS snapshot
      description: |
        Latest DNS record snapshot for the monitor. 404 until the DNS check
        has run at least once.
      operationId: getDomainMonitorDnsSnapshot
      tags: [Domain Monitoring]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Current DNS snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  # Anchor lives here (not /ssl): the bundler sorts paths
                  # lexically, and /dns is the first snapshot path emitted.
                  data: &DomainMonitorSnapshotSchema
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      domain_monitor_id:
                        type: string
                        format: uuid
                      check_type:
                        type: string
                        enum: [ssl, dns, whois, dns_security]
                      data:
                        type: object
                        description: Check-type-specific snapshot payload
                      previous_data:
                        type: object
                        nullable: true
                        description: Snapshot from the prior run, for change detection
                      updated_at:
                        type: string
                        format: date-time
              example:
                data:
                  id: "6f7a8b9c-0d1e-4f2a-b3c4-d5e6f7a8b9c0"
                  domain_monitor_id: "7f3c2a10-9b4e-4c6d-8a21-5e9f0d3b6c44"
                  check_type: "dns"
                  data:
                    a: ["203.0.113.20"]
                    mx: ["10 mx-eu.opsmerge.cloud."]
                    ns: ["ns1.example-dns.net.", "ns2.example-dns.net."]
                  previous_data: null
                  updated_at: "2026-06-10T09:20:05Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /domain-monitors/{id}/dns-security:
    get:
      x-stability: stable
      x-required-scope: domain-monitors:read
      summary: Get the current DNS-security snapshot
      description: |
        Latest email/DNS security snapshot (DMARC, SPF, DKIM) for the
        monitor. 404 until the DNS-security check has run at least once.
      operationId: getDomainMonitorDnsSecuritySnapshot
      tags: [Domain Monitoring]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Current DNS-security snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *DomainMonitorSnapshotSchema
              example:
                data:
                  id: "8b9c0d1e-2f3a-4b4c-d5e6-f7a8b9c0d1e2"
                  domain_monitor_id: "7f3c2a10-9b4e-4c6d-8a21-5e9f0d3b6c44"
                  check_type: "dns_security"
                  data:
                    dmarc:
                      present: true
                      policy: "quarantine"
                    spf:
                      present: true
                      record: "v=spf1 include:_spf.opsmerge.cloud -all"
                  previous_data: null
                  updated_at: "2026-06-10T09:20:07Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /domain-monitors/{id}/history:
    get:
      x-stability: stable
      x-required-scope: domain-monitors:read
      summary: List check result history
      description: |
        Paginated historical check results for the monitor, newest first.
        Each row is one sub-check execution; `details` is a
        check-type-specific payload.
      operationId: listDomainMonitorHistory
      tags: [Domain Monitoring]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/PageParam'
        - name: page_size
          in: query
          description: Items per page (max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: check_type
          in: query
          description: Only results for this check type
          schema:
            type: string
            enum: [ssl, dns, whois, dns_security]
      responses:
        "200":
          description: Paginated check result history
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            domain_monitor_id:
                              type: string
                              format: uuid
                            check_type:
                              type: string
                              enum: [ssl, dns, whois, dns_security]
                            status:
                              type: string
                              enum: [ok, warning, error]
                            message:
                              type: string
                              nullable: true
                            details:
                              type: object
                              nullable: true
                              description: Check-type-specific result detail
                            response_time_ms:
                              type: integer
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                      pagination:
                        $ref: '#/components/schemas/PaginationMeta'
              example:
                data:
                  items:
                    - id: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
                      domain_monitor_id: "7f3c2a10-9b4e-4c6d-8a21-5e9f0d3b6c44"
                      check_type: "ssl"
                      status: "ok"
                      message: "certificate valid, 39 days until expiry"
                      details:
                        issuer: "Example CA"
                        not_after: "2026-07-19T23:59:59Z"
                        days_remaining: 39
                      response_time_ms: 184
                      created_at: "2026-06-10T09:20:04Z"
                  pagination:
                    page: 1
                    per_page: 25
                    total: 412
                    total_pages: 17
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /domain-monitors/{id}/pause:
    put:
      x-stability: stable
      x-required-scope: domain-monitors:write
      summary: Pause or resume a domain monitor
      description: Paused monitors are skipped by the scheduled check dispatcher.
      operationId: pauseDomainMonitor
      tags: [Domain Monitoring]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [paused]
              properties:
                paused:
                  type: boolean
                  description: true to pause scheduled checks, false to resume
            example:
              paused: true
      responses:
        "200":
          description: New paused state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      paused:
                        type: boolean
              example:
                data:
                  paused: true
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /domain-monitors/{id}/refresh:
    post:
      x-stability: stable
      x-required-scope: domain-monitors:write
      summary: Trigger an immediate check run
      description: |
        Enqueues a background check job for all enabled check types on this
        monitor. Returns 202 once the job is queued; results land in the
        history and snapshot endpoints when the run completes.
      operationId: refreshDomainMonitor
      tags: [Domain Monitoring]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "202":
          description: Check run queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [queued]
              example:
                data:
                  status: "queued"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /domain-monitors/{id}/ssl:
    get:
      x-stability: stable
      x-required-scope: domain-monitors:read
      summary: Get the current SSL snapshot
      description: |
        Latest SSL certificate snapshot for the monitor. 404 until the SSL
        check has run at least once.
      operationId: getDomainMonitorSslSnapshot
      tags: [Domain Monitoring]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Current SSL snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *DomainMonitorSnapshotSchema
              example:
                data:
                  id: "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9"
                  domain_monitor_id: "7f3c2a10-9b4e-4c6d-8a21-5e9f0d3b6c44"
                  check_type: "ssl"
                  data:
                    issuer: "Example CA"
                    subject: "acmemanaged.example"
                    not_before: "2026-04-20T00:00:00Z"
                    not_after: "2026-07-19T23:59:59Z"
                    days_remaining: 39
                    sans: ["acmemanaged.example", "www.acmemanaged.example"]
                  previous_data: null
                  updated_at: "2026-06-10T09:20:04Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /domain-monitors/{id}/whois:
    get:
      x-stability: stable
      x-required-scope: domain-monitors:read
      summary: Get the current WHOIS snapshot
      description: |
        Latest WHOIS registration snapshot for the monitor. 404 until the
        WHOIS check has run at least once.
      operationId: getDomainMonitorWhoisSnapshot
      tags: [Domain Monitoring]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Current WHOIS snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *DomainMonitorSnapshotSchema
              example:
                data:
                  id: "7a8b9c0d-1e2f-4a3b-c4d5-e6f7a8b9c0d1"
                  domain_monitor_id: "7f3c2a10-9b4e-4c6d-8a21-5e9f0d3b6c44"
                  check_type: "whois"
                  data:
                    registrar: "Example Registrar Ltd"
                    expiry_date: "2027-03-01T00:00:00Z"
                    days_until_expiry: 264
                  previous_data: null
                  updated_at: "2026-06-10T09:20:06Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /installers/stub-download:
    get:
      x-stability: beta
      summary: Download the single-EXE installer stub (public, token-authorised)
      description: |
        Serves the current signed Windows (amd64) installer stub, named so the
        registration token travels **in the filename**:

        ```
        opsmerge-setup-<region>-<token>.exe
        ```

        Possession of a valid, unexpired registration token in the `token`
        query parameter is the authorisation — there is no bearer auth, same
        contract as `/agent-versions/current/download?token=`. The stub parses
        its own filename at runtime to recover the token, and the rename does
        not disturb the Authenticode signature, so one signed binary per
        release serves every token. Raw tokens are base64url and therefore
        filename-safe by construction.

        Org-scoped: the token's organisation selects a per-org stub build if
        one is published, falling back to the global stub.
      operationId: downloadInstallerStub
      security: []
      tags: [Agent Enrolment]
      parameters:
        - name: token
          in: query
          required: true
          description: Raw registration token (base64url). This is the sole authorisation.
          schema:
            type: string
      responses:
        "200":
          description: Signed installer stub binary
          headers:
            Content-Disposition:
              description: 'attachment; filename="opsmerge-setup-<region>-<token>.exe"'
              schema:
                type: string
            X-Checksum-SHA256:
              description: SHA-256 checksum of the binary (hex)
              schema:
                type: string
            X-Signature-Ed25519:
              description: Ed25519 signature of the binary (hex)
              schema:
                type: string
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "400":
          description: Missing token query parameter
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          description: Invalid or expired registration token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "404":
          description: No installer stub has been published
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: Get Bitdefender connection status
      description: |
        Sanitised connection view — never returns the API key or push
        secret. When no connection exists, returns 200 with
        `has_connection=false` rather than 404 so the UI can render the
        onboarding state.
      operationId: getBitdefenderStatus
      tags: [Bitdefender]
      responses:
        "200":
          description: Connection status (or has_connection=false)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      has_connection: {type: boolean}
                      enabled: {type: boolean}
                      access_url: {type: string}
                      partner_company_id: {type: string}
                      region_label: {type: string}
                      default_template_policy_id: {type: string}
                      auto_install_on_new_agents: {type: boolean}
                      status: {type: string}
                      last_synced_at: {type: string, format: date-time, nullable: true}
                      last_error: {type: string}
                      connected_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: endpoint-security:write
      summary: Disconnect Bitdefender
      description: |
        Clean break — the connection row is deleted, credentials are not
        retained. Returns 200 with a status marker (not 204).
      operationId: disconnectBitdefender
      tags: [Bitdefender]
      responses:
        "200":
          description: Disconnected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [disconnected]}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /integrations/bitdefender/clients/{client_id}/endpoints:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: List Bitdefender endpoints for a client
      description: Per-client endpoint cache rows for the client card's agent table.
      operationId: listBitdefenderClientEndpoints
      tags: [Bitdefender]
      parameters:
        - name: client_id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Endpoint cache rows (empty array when none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        connection_id: {type: string, format: uuid}
                        company_id: {type: string, format: uuid, nullable: true}
                        agent_id: {type: string, format: uuid, nullable: true}
                        bd_endpoint_id: {type: string}
                        bd_company_id: {type: string}
                        fqdn: {type: string}
                        hostname: {type: string}
                        macs:
                          type: array
                          items: {type: string}
                        ips:
                          type: array
                          items: {type: string}
                        operating_system: {type: string}
                        label: {type: string}
                        policy_id: {type: string}
                        modules_running:
                          description: Raw JSON module map as reported by Bitdefender
                        install_status: {type: string}
                        install_message: {type: string}
                        install_dispatched_at: {type: string, format: date-time, nullable: true}
                        install_completed_at: {type: string, format: date-time, nullable: true}
                        last_seen_at: {type: string, format: date-time, nullable: true}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "400":
          description: Invalid client_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /integrations/bitdefender/companies:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: List provisioned Bitdefender companies
      description: |
        Per-OpsMerge-client cache view of every Bitdefender child company
        under this MSP's connection.
      operationId: listBitdefenderCompanies
      tags: [Bitdefender]
      responses:
        "200":
          description: Company cache rows (empty array when none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &BitdefenderCompany
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        client_id: {type: string, format: uuid}
                        bd_company_id: {type: string}
                        bd_company_name: {type: string}
                        reserved_slots: {type: integer}
                        end_subscription: {type: string, format: date-time, nullable: true}
                        package_id: {type: string}
                        package_name: {type: string}
                        assigned_policy_id: {type: string}
                        provisioning_status: {type: string}
                        provisioning_step: {type: string}
                        last_provisioned_at: {type: string, format: date-time, nullable: true}
                        last_error: {type: string}
                        updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /integrations/bitdefender/companies/{client_id}:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: Get the Bitdefender company for one client
      description: |
        Returns the cache row for one OpsMerge client. When the client has
        no Bitdefender company, returns 200 with `data: null` (NOT 404).
      operationId: getBitdefenderClientCompany
      tags: [Bitdefender]
      parameters:
        - name: client_id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Company cache row, or null when not provisioned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *BitdefenderCompany
        "400":
          description: Invalid client_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /integrations/bitdefender/connect:
    post:
      x-stability: internal
      x-required-scope: endpoint-security:write
      summary: Connect Bitdefender GravityZone
      description: |
        Saves the MSP's own GravityZone Access URL + API key
        (bring-your-own-tenant). Credentials are validated live via
        `general.getApiKeyDetails` before persist; on success the push
        receiver is registered with Bitdefender (best-effort). The
        access_url must be https and publicly routable (SSRF-guarded);
        common pasted path suffixes (/api, /v1.0/jsonrpc, ...) are
        stripped automatically.
      operationId: connectBitdefender
      tags: [Bitdefender]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [access_url, api_key]
              properties:
                access_url: {type: string, description: GravityZone API Access URL (https)}
                api_key: {type: string}
      responses:
        "200":
          description: Connected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [connected]}
        "400":
          description: Validation failure (VALIDATION_ERROR / INVALID_BODY)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: Credential check failed (BITDEFENDER_AUTH_FAILED or BITDEFENDER_UNREACHABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/costs:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: List Bitdefender module cost rows
      description: Per-MSP module pricing rows used by the billing reconciler.
      operationId: listBitdefenderCosts
      tags: [Bitdefender]
      responses:
        "200":
          description: Cost rows (empty array when none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        module_key: {type: string}
                        description: {type: string}
                        workstation_cents: {type: integer}
                        server_cents: {type: integer}
                        service_id: {type: string, format: uuid, nullable: true}
                        tax_code_id: {type: string, format: uuid, nullable: true}
                        enabled: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    put:
      x-stability: internal
      x-required-scope: endpoint-security:write
      summary: Upsert a Bitdefender module cost row
      description: |
        Writes (or replaces, keyed on module_key) one cost row. Prices
        arrive in pounds and are converted to integer cents server-side.
      operationId: upsertBitdefenderCost
      tags: [Bitdefender]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [module_key]
              properties:
                module_key: {type: string}
                description: {type: string}
                workstation_pounds: {type: number}
                server_pounds: {type: number}
                service_id: {type: string, format: uuid, nullable: true}
                tax_code_id: {type: string, format: uuid, nullable: true}
                enabled: {type: boolean}
      responses:
        "200":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [saved]}
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /integrations/bitdefender/costs/{module_key}:
    delete:
      x-stability: internal
      x-required-scope: endpoint-security:write
      summary: Delete a Bitdefender module cost row
      description: Hard-deletes the row. Returns 200 with a status marker (not 204).
      operationId: deleteBitdefenderCost
      tags: [Bitdefender]
      parameters:
        - name: module_key
          in: path
          required: true
          schema: {type: string}
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [deleted]}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /integrations/bitdefender/defaults:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: Get Bitdefender provisioning defaults
      description: |
        The MSP's saved per-customer provisioning defaults. Returns an
        all-zero-values view when not connected (not 404).
      operationId: getBitdefenderDefaults
      tags: [Bitdefender]
      responses:
        "200":
          description: Provisioning defaults
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      template_policy_id: {type: string}
                      reserved_slots: {type: integer}
                      subscription_addons:
                        type: object
                        additionalProperties: {type: boolean}
                      package_modules:
                        type: object
                        additionalProperties: {type: boolean}
                      auto_install_on_new_agents: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    put:
      x-stability: internal
      x-required-scope: endpoint-security:write
      summary: Save Bitdefender provisioning defaults
      operationId: saveBitdefenderDefaults
      tags: [Bitdefender]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                template_policy_id: {type: string}
                reserved_slots: {type: integer, minimum: 0}
                subscription_addons:
                  type: object
                  additionalProperties: {type: boolean}
                package_modules:
                  type: object
                  additionalProperties: {type: boolean}
                auto_install_on_new_agents: {type: boolean}
      responses:
        "200":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [saved]}
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Bitdefender not connected (BITDEFENDER_NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/incidents/{incident_id}:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: Get one Bitdefender incident
      description: |
        Live proxy of `incidents.getIncident` — enriches an alert row with
        current Bitdefender state. Field names are Bitdefender's own
        camelCase, passed through unmodified.
      operationId: getBitdefenderIncident
      tags: [Bitdefender]
      parameters:
        - name: incident_id
          in: path
          required: true
          schema: {type: string}
      responses:
        "200":
          description: Incident detail (upstream camelCase keys)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string}
                      type: {type: string}
                      companyId: {type: string}
                      endpointId: {type: string}
                      createdAt: {type: string}
                      severity: {type: string}
                      status: {type: string}
                      title: {type: string}
                      summary: {type: string}
                      mitreTechnique: {type: string}
                      properties:
                        type: object
                        additionalProperties: true
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Bitdefender not connected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Upstream Bitdefender call failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/policies:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: List Bitdefender template policies
      description: |
        Live proxy of `policies.getPoliciesList` for the defaults UI
        dropdown. Pages through up to 5 upstream pages (x100) and returns
        a flat array — no pagination parameters.
      operationId: listBitdefenderPolicies
      tags: [Bitdefender]
      responses:
        "200":
          description: Policies
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string}
                        name: {type: string}
                        description: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Bitdefender not connected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Upstream Bitdefender call failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/provision/{client_id}:
    post:
      x-stability: internal
      x-required-scope: endpoint-security:write
      summary: Provision a Bitdefender company for one client
      description: |
        Runs the full provisioning chain synchronously. Requires the
        client's bd_enabled toggle to be on (409 otherwise). On partial
        progress returns 202 with the partially-progressed company view
        (retrying resumes from the failed step).
      operationId: provisionBitdefenderClient
      tags: [Bitdefender]
      parameters:
        - name: client_id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Fully provisioned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *BitdefenderCompany
        "202":
          description: Partial progress — see provisioning_step / last_error
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *BitdefenderCompany
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Client not found or Bitdefender not connected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Bitdefender not enabled for this client (BITDEFENDER_DISABLED_FOR_CLIENT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provisioning failed with no partial progress (BITDEFENDER_PROVISION_FAILED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/push:
    post:
      x-stability: internal
      operationId: receiveBitdefenderPush
      summary: Bitdefender push event webhook
      description: |
        Receives Bitdefender GravityZone push events (JSON-RPC 2.0 envelope,
        method `pushEvent`/`addEvents`/event-type). Authentication is HTTP
        Basic — `Authorization: Basic base64(<org_id>:<push_secret>)` — checked
        against the per-MSP push_secret on bitdefender_connections (Bitdefender
        offers no HMAC; the secret is rotated automatically on every Connect
        call). Body capped at 512 KB. After auth, malformed bodies are
        persisted to bitdefender_push_log for forensic replay and ACKed with
        200 so retries don't loop. Errors use the standard JSON error envelope.
      tags: ["Webhooks (Inbound)"]
      security: []
      parameters:
        - name: Authorization
          in: header
          required: true
          description: "`Basic base64(<org_id>:<push_secret>)`."
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: JSON-RPC 2.0 envelope.
              properties:
                jsonrpc:
                  type: string
                  example: "2.0"
                method:
                  type: string
                  description: "`pushEvent`, an event-type name, or `addEvents` (batched)."
                params:
                  type: object
                  description: 'Single event fields, or `{"events": [...]}` for batches.'
                id:
                  description: "JSON-RPC id: string, number, or null."
                  nullable: true
      responses:
        "200":
          description: Events routed (or empty/unparseable body acknowledged). Body `{"status":"ok"}` or `{"status":"ignored"}`.
        "400":
          description: Body read failure.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          description: 'Missing Basic credentials (response carries `WWW-Authenticate: Basic realm="bitdefender-push"`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "403":
          description: Credentials present but org/secret mismatch.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          description: Auth lookup failure.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/push/health:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: Get Bitdefender push delivery stats
      description: |
        Live proxy of the push event stats from Bitdefender (no cache).
        This is the authenticated health read for the settings page — the
        push receiver itself is a public webhook and is not documented
        here.
      operationId: getBitdefenderPushHealth
      tags: [Bitdefender]
      responses:
        "200":
          description: Delivery stats
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total_events: {type: integer, format: int64}
                      undelivered_events: {type: integer, format: int64}
                      last_delivery_at: {type: string}
                      last_error_at: {type: string}
                      last_error_message: {type: string}
                      fetched_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Bitdefender not connected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Upstream Bitdefender call failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/reconcile:
    post:
      x-stability: internal
      x-required-scope: endpoint-security:write
      summary: Run Bitdefender billing reconciliation now
      description: |
        Synchronous out-of-band run of the billing reconciler for the
        calling tenant. Returns 503 when the reconciler is not wired
        in this process.
      operationId: reconcileBitdefenderBillingNow
      tags: [Bitdefender]
      responses:
        "200":
          description: Reconcile result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      org_id: {type: string, format: uuid}
                      companies_processed: {type: integer}
                      snapshots_upserted: {type: integer}
                      snapshots_locked: {type: integer}
                      invoices_drafted: {type: integer}
                      invoice_lines_inserted: {type: integer}
                      errors:
                        type: array
                        nullable: true
                        items: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: Reconcile failed (BITDEFENDER_RECON_FAILED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Billing reconciler not wired in this process
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/reports:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: List Bitdefender reports
      description: |
        Live proxy of `reports.getReportsList`, optionally scoped to one
        Bitdefender company. Pages through up to 5 upstream pages (x100)
        and returns a flat array with Bitdefender's camelCase keys.
      operationId: listBitdefenderReports
      tags: [Bitdefender]
      parameters:
        - name: bd_company_id
          in: query
          description: Bitdefender company id to scope the list
          schema: {type: string}
      responses:
        "200":
          description: Reports (upstream camelCase keys)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string}
                        name: {type: string}
                        type: {type: integer}
                        scheduled: {type: boolean}
                        createdAt: {type: string}
                        companyId: {type: string}
                        description: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Bitdefender not connected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Upstream Bitdefender call failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/reports/{report_id}/download:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: Get Bitdefender report download links
      description: |
        Returns presigned Bitdefender download URLs — the content is NOT
        proxied; the client fetches directly from Bitdefender.
      operationId: getBitdefenderReportDownloadLinks
      tags: [Bitdefender]
      parameters:
        - name: report_id
          in: path
          required: true
          schema: {type: string}
      responses:
        "200":
          description: Download links
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      pdf: {type: string}
                      csv: {type: string}
                      html: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Bitdefender not connected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Upstream Bitdefender call failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/test:
    post:
      x-stability: internal
      x-required-scope: endpoint-security:write
      summary: Test Bitdefender credentials
      description: |
        Verifies an unsaved Access URL + API key pair via
        `general.getApiKeyDetails`. Nothing is persisted.
      operationId: testBitdefenderCredentials
      tags: [Bitdefender]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [access_url, api_key]
              properties:
                access_url: {type: string}
                api_key: {type: string}
      responses:
        "200":
          description: Credentials verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: {type: boolean}
                      partner_company_id: {type: string}
                      region_label: {type: string}
                      description: {type: string}
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: Credential check failed (BITDEFENDER_AUTH_FAILED or BITDEFENDER_UNREACHABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /integrations/bitdefender/usage:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: List Bitdefender usage snapshots
      description: |
        Monthly billable-usage snapshot rows for the settings page.
        `limit` caps the rows returned (default 200, max 500) — no
        page/offset paging.
      operationId: listBitdefenderUsageSnapshots
      tags: [Bitdefender]
      parameters:
        - name: limit
          in: query
          schema: {type: integer, default: 200, maximum: 500}
      responses:
        "200":
          description: Snapshot rows (empty array when none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &BitdefenderUsageSnapshot
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        connection_id: {type: string, format: uuid}
                        client_id: {type: string, format: uuid, nullable: true}
                        bd_company_id: {type: string}
                        bd_company_name: {type: string}
                        usage_year: {type: integer}
                        usage_month: {type: integer}
                        used_slots: {type: integer}
                        product_usage:
                          description: Raw JSON product-usage map from Bitdefender
                        missing_modules:
                          description: Raw JSON array of module keys with usage but no cost row
                        invoice_id: {type: string, format: uuid, nullable: true}
                        generated_invoice_item_ids:
                          description: Raw JSON array of generated invoice item ids
                        first_observed_at: {type: string, format: date-time}
                        last_observed_at: {type: string, format: date-time}
                        snapshot_locked_at: {type: string, format: date-time, nullable: true}
                        last_error: {type: string}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /integrations/bitdefender/usage/clients/{client_id}:
    get:
      x-stability: internal
      x-required-scope: endpoint-security:read
      summary: List Bitdefender usage snapshots for one client
      description: Last 12 snapshot rows for the per-client usage timeline.
      operationId: listBitdefenderClientUsageSnapshots
      tags: [Bitdefender]
      parameters:
        - name: client_id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Snapshot rows (empty array when none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: *BitdefenderUsageSnapshot
        "400":
          description: Invalid client_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /integrations/msp360:
    get:
      x-stability: internal
      x-required-scope: backups:read
      summary: Get MSP360 connection status
      description: Sanitised view — credentials are never returned.
      operationId: getMsp360Status
      tags: [Backups]
      responses:
        "200":
          description: Connection view (has_connection=false when not configured)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      has_connection: {type: boolean}
                      enabled: {type: boolean}
                      base_url: {type: string}
                      api_login: {type: string}
                      alert_on_failure: {type: boolean}
                      alert_on_warning: {type: boolean}
                      alert_on_success: {type: boolean}
                      last_sync_at: {type: string, format: date-time, nullable: true}
                      last_sync_status: {type: string}
                      last_sync_error: {type: string}
                      last_sync_count: {type: integer, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: backups:write
      summary: Disconnect MSP360
      description: |
        Removes the stored credentials but keeps historical endpoints and
        plans so agent mappings survive a reconnect.
      operationId: disconnectMsp360
      tags: [Backups]
      responses:
        "204":
          description: Disconnected
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /integrations/msp360/connect:
    post:
      x-stability: internal
      x-required-scope: backups:write
      summary: Connect MSP360 (validate and store credentials)
      description: |
        Validates the credentials against /Provider/Login before storing
        them encrypted. base_url must be https and publicly routable
        (SSRF-guarded); defaults to the MSP360 cloud URL when omitted.
      operationId: connectMsp360
      tags: [Backups]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [api_login, api_password]
              properties:
                base_url: {type: string}
                api_login: {type: string}
                api_password: {type: string}
                alert_on_failure: {type: boolean, nullable: true, default: true}
                alert_on_warning: {type: boolean, nullable: true, default: true}
                alert_on_success: {type: boolean, nullable: true, default: false}
      responses:
        "200":
          description: Connected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: connected}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: MSP360 login failed
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /integrations/msp360/preferences:
    patch:
      x-stability: internal
      x-required-scope: backups:write
      summary: Update MSP360 alert preferences
      description: Toggles the alert_on_* flags without re-entering credentials.
      operationId: updateMsp360Preferences
      tags: [Backups]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                alert_on_failure: {type: boolean, nullable: true}
                alert_on_warning: {type: boolean, nullable: true}
                alert_on_success: {type: boolean, nullable: true}
      responses:
        "204":
          description: Preferences updated
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /integrations/msp360/sync:
    post:
      x-stability: internal
      x-required-scope: backups:write
      summary: Trigger an immediate MSP360 sync
      description: |
        Runs the per-org poll inline. Throttled to one manual sync per
        minute (429 with Retry-After). No request body.
      operationId: syncMsp360Now
      tags: [Backups]
      responses:
        "200":
          description: Sync completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: ok}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: MSP360 sync failed
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /kb/articles:
    get:
      x-stability: stable
      x-required-scope: kb:read
      summary: List or search knowledge base articles
      description: |
        Without `q`, returns articles filtered by visibility and optional
        category, paginated by `limit`/`offset`. With `q`, performs full-text
        search (Postgres FTS with a trigram fallback) and returns ranked
        search results with an excerpt instead of full article rows —
        `offset` and `category_id` are ignored in search mode and the search
        `limit` is clamped to 50 (default 20).

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:read` scope.
      operationId: listKbArticles
      tags: [Knowledge Base]
      parameters:
        - name: q
          in: query
          description: Full-text search query. When set, the response switches to ranked search results.
          schema:
            type: string
        - name: category_id
          in: query
          description: Filter to a single category (ignored in search mode)
          schema:
            type: string
            format: uuid
        - name: visibility
          in: query
          description: "Filter by visibility. Default: both private and internal."
          schema:
            type: string
            enum: [private, internal]
        - name: limit
          in: query
          description: Maximum rows returned (list mode default 50; search mode clamped to 50, default 20)
          schema:
            type: integer
            default: 50
        - name: offset
          in: query
          description: Rows to skip (list mode only)
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: Article list (or search results when `q` is set)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        description: Article rows in list mode; ranked search results (id/title/visibility/excerpt/updated_at/rank) in search mode
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            category_id:
                              type: string
                              format: uuid
                              nullable: true
                            slug:
                              type: string
                              nullable: true
                            title:
                              type: string
                            summary:
                              type: string
                            body:
                              type: string
                              description: Markdown article body
                            visibility:
                              type: string
                              enum: [private, internal]
                            author_id:
                              type: string
                              format: uuid
                            published_at:
                              type: string
                              format: date-time
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
                      query:
                        type: string
                        description: Echo of the search query (search mode only)
              example:
                data:
                  items:
                    - id: "3f2c1f7e-9a4b-4c2d-8e1f-aa11bb22cc33"
                      org_id: "0b8d2c44-1234-4e5f-9a6b-77ccdd88ee99"
                      category_id: "c9e7a4d2-5b6c-4f8e-9d0a-112233445566"
                      slug: "reset-vpn-profile"
                      title: "Resetting a corrupted VPN profile"
                      summary: "Steps to rebuild the AlwaysOn VPN profile on Windows 11."
                      body: "## Symptoms\nVPN stuck at *Connecting*...\n"
                      visibility: internal
                      author_id: "7a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                      published_at: "2026-05-01T09:12:00Z"
                      created_at: "2026-05-01T09:12:00Z"
                      updated_at: "2026-06-02T14:30:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
    post:
      x-stability: stable
      x-required-scope: kb:write
      summary: Create a knowledge base article
      description: |
        Creates an article (Markdown body) and seeds version 1 of its history.
        `visibility` controls who can read it: `private` (MSP technicians
        only) or `internal` (also visible to client portal contacts).

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:write` scope.
      operationId: createKbArticle
      tags: [Knowledge Base]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, visibility]
              properties:
                title:
                  type: string
                summary:
                  type: string
                body:
                  type: string
                  description: Markdown article body
                visibility:
                  type: string
                  enum: [private, internal]
                category_id:
                  type: string
                  format: uuid
                  nullable: true
                tags:
                  type: array
                  items:
                    type: string
            example:
              title: "Resetting a corrupted VPN profile"
              summary: "Steps to rebuild the AlwaysOn VPN profile on Windows 11."
              body: "## Symptoms\nVPN stuck at *Connecting*..."
              visibility: internal
              category_id: "c9e7a4d2-5b6c-4f8e-9d0a-112233445566"
              tags: [vpn, windows11]
      responses:
        "201":
          description: Article created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
              example:
                data:
                  id: "3f2c1f7e-9a4b-4c2d-8e1f-aa11bb22cc33"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /kb/articles/by-slug/{slug}:
    get:
      x-stability: stable
      x-required-scope: kb:read
      summary: Get a knowledge base article by slug
      description: |
        Returns the article and its tags by URL slug (e.g. from a `/kb/<slug>`
        deep link). Same article shape as `GET /kb/articles/{id}` but without
        the version list. Slugs are nullable on legacy rows; those articles
        are only reachable by ID.

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:read` scope.
      operationId: getKbArticleBySlug
      tags: [Knowledge Base]
      parameters:
        - name: slug
          in: path
          required: true
          description: Article slug
          schema:
            type: string
      responses:
        "200":
          description: Article with tags
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      article:
                        type: object
                        description: KB article row (see GET /kb/articles)
                      tags:
                        type: array
                        items:
                          type: string
              example:
                data:
                  article:
                    id: "3f2c1f7e-9a4b-4c2d-8e1f-aa11bb22cc33"
                    org_id: "0b8d2c44-1234-4e5f-9a6b-77ccdd88ee99"
                    category_id: "c9e7a4d2-5b6c-4f8e-9d0a-112233445566"
                    slug: "reset-vpn-profile"
                    title: "Resetting a corrupted VPN profile"
                    summary: "Steps to rebuild the AlwaysOn VPN profile on Windows 11."
                    body: "## Symptoms\nVPN stuck at *Connecting*..."
                    visibility: internal
                    author_id: "7a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                    published_at: "2026-05-01T09:12:00Z"
                    created_at: "2026-05-01T09:12:00Z"
                    updated_at: "2026-06-02T14:30:00Z"
                  tags: [vpn, windows11]
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /kb/articles/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Article ID
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: kb:read
      summary: Get a knowledge base article
      description: |
        Returns the article, its tags, and its full version history.

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:read` scope.
      operationId: getKbArticle
      tags: [Knowledge Base]
      responses:
        "200":
          description: Article with tags and versions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      article:
                        type: object
                        description: KB article row (see GET /kb/articles)
                      tags:
                        type: array
                        items:
                          type: string
                      versions:
                        type: array
                        description: Prior versions, one row per edit
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            article_id:
                              type: string
                              format: uuid
                            version_n:
                              type: integer
                            title:
                              type: string
                            summary:
                              type: string
                            body:
                              type: string
                            edited_by:
                              type: string
                              format: uuid
                            edited_at:
                              type: string
                              format: date-time
              example:
                data:
                  article:
                    id: "3f2c1f7e-9a4b-4c2d-8e1f-aa11bb22cc33"
                    org_id: "0b8d2c44-1234-4e5f-9a6b-77ccdd88ee99"
                    category_id: "c9e7a4d2-5b6c-4f8e-9d0a-112233445566"
                    slug: "reset-vpn-profile"
                    title: "Resetting a corrupted VPN profile"
                    summary: "Steps to rebuild the AlwaysOn VPN profile on Windows 11."
                    body: "## Symptoms\nVPN stuck at *Connecting*..."
                    visibility: internal
                    author_id: "7a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                    published_at: "2026-05-01T09:12:00Z"
                    created_at: "2026-05-01T09:12:00Z"
                    updated_at: "2026-06-02T14:30:00Z"
                  tags: [vpn, windows11]
                  versions:
                    - id: "9e8d7c6b-5a49-4838-2716-051423324150"
                      org_id: "0b8d2c44-1234-4e5f-9a6b-77ccdd88ee99"
                      article_id: "3f2c1f7e-9a4b-4c2d-8e1f-aa11bb22cc33"
                      version_n: 1
                      title: "Resetting a corrupted VPN profile"
                      summary: "Initial draft."
                      body: "## Symptoms\nVPN stuck..."
                      edited_by: "7a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                      edited_at: "2026-05-01T09:12:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
    put:
      x-stability: stable
      x-required-scope: kb:write
      summary: Update a knowledge base article
      description: |
        Snapshots the current content as a new version row, then applies the
        new title/summary/body and replaces the tag set. Visibility and
        category cannot be changed via this endpoint.

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:write` scope.
      operationId: updateKbArticle
      tags: [Knowledge Base]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                summary:
                  type: string
                body:
                  type: string
                  description: Markdown article body
                tags:
                  type: array
                  description: Replaces the full tag set (empty array clears all tags)
                  items:
                    type: string
            example:
              title: "Resetting a corrupted VPN profile"
              summary: "Steps to rebuild the AlwaysOn VPN profile on Windows 11 and 10."
              body: "## Symptoms\nVPN stuck at *Connecting*...\n\n## Fix\n1. Remove the profile..."
              tags: [vpn, windows11, windows10]
      responses:
        "200":
          description: Article updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
              example:
                data:
                  id: "3f2c1f7e-9a4b-4c2d-8e1f-aa11bb22cc33"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
    delete:
      x-stability: stable
      x-required-scope: kb:write
      summary: Delete a knowledge base article
      description: |
        Hard-deletes the article. Tags and version history cascade-delete.

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:write` scope.
      operationId: deleteKbArticle
      tags: [Knowledge Base]
      responses:
        "204":
          description: Article deleted
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /kb/articles/{id}/restore/{version}:
    post:
      x-stability: stable
      x-required-scope: kb:write
      summary: Restore a prior version of a knowledge base article
      description: |
        Copies the specified version's content back onto the live article,
        first recording the current state as a new version row (so a restore
        is itself undoable). No request body.

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:write` scope.
      operationId: restoreKbArticleVersion
      tags: [Knowledge Base]
      parameters:
        - name: id
          in: path
          required: true
          description: Article ID
          schema:
            type: string
            format: uuid
        - name: version
          in: path
          required: true
          description: Version number to restore (1-based)
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: Version restored
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      restored_version:
                        type: integer
              example:
                data:
                  id: "3f2c1f7e-9a4b-4c2d-8e1f-aa11bb22cc33"
                  restored_version: 2
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /kb/articles/{id}/versions:
    get:
      x-stability: stable
      x-required-scope: kb:read
      summary: List versions of a knowledge base article
      description: |
        Returns the article's version history, one row per edit, capturing the
        state **before** each update. Restore a version with
        `POST /kb/articles/{id}/restore/{version}`.

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:read` scope.
      operationId: listKbArticleVersions
      tags: [Knowledge Base]
      parameters:
        - name: id
          in: path
          required: true
          description: Article ID
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Version history
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            article_id:
                              type: string
                              format: uuid
                            version_n:
                              type: integer
                            title:
                              type: string
                            summary:
                              type: string
                            body:
                              type: string
                            edited_by:
                              type: string
                              format: uuid
                            edited_at:
                              type: string
                              format: date-time
              example:
                data:
                  items:
                    - id: "9e8d7c6b-5a49-4838-2716-051423324150"
                      org_id: "0b8d2c44-1234-4e5f-9a6b-77ccdd88ee99"
                      article_id: "3f2c1f7e-9a4b-4c2d-8e1f-aa11bb22cc33"
                      version_n: 2
                      title: "Resetting a corrupted VPN profile"
                      summary: "Steps to rebuild the AlwaysOn VPN profile on Windows 11."
                      body: "## Symptoms\nVPN stuck..."
                      edited_by: "7a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                      edited_at: "2026-06-02T14:30:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /kb/categories:
    get:
      x-stability: stable
      x-required-scope: kb:read
      summary: List knowledge base categories
      description: |
        Returns a flat list of all categories; build the tree client-side via
        `parent_id`. Categories nest to a maximum depth of 4.

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:read` scope.
      operationId: listKbCategories
      tags: [Knowledge Base]
      responses:
        "200":
          description: Flat category list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            name:
                              type: string
                            parent_id:
                              type: string
                              format: uuid
                              nullable: true
                            sort_order:
                              type: integer
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
              example:
                data:
                  items:
                    - id: "c9e7a4d2-5b6c-4f8e-9d0a-112233445566"
                      org_id: "0b8d2c44-1234-4e5f-9a6b-77ccdd88ee99"
                      name: "Networking"
                      parent_id: null
                      sort_order: 0
                      created_at: "2026-04-12T10:00:00Z"
                      updated_at: "2026-04-12T10:00:00Z"
                    - id: "d1f2e3a4-b5c6-4d7e-8f90-aabbccddeeff"
                      org_id: "0b8d2c44-1234-4e5f-9a6b-77ccdd88ee99"
                      name: "VPN"
                      parent_id: "c9e7a4d2-5b6c-4f8e-9d0a-112233445566"
                      sort_order: 1
                      created_at: "2026-04-12T10:05:00Z"
                      updated_at: "2026-04-12T10:05:00Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
    post:
      x-stability: stable
      x-required-scope: kb:write
      summary: Create a knowledge base category
      description: |
        Creates a category, optionally nested under a parent. The tree is
        capped at depth 4 (root = depth 1); exceeding the cap returns a 422
        whose body is `{"data": {"error": "depth_exceeded", "max_depth": 4}}`.

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:write` scope.
      operationId: createKbCategory
      tags: [Knowledge Base]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                parent_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Parent category (omit or null for a root category)
                sort_order:
                  type: integer
                  default: 0
            example:
              name: "VPN"
              parent_id: "c9e7a4d2-5b6c-4f8e-9d0a-112233445566"
              sort_order: 1
      responses:
        "201":
          description: Category created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
              example:
                data:
                  id: "d1f2e3a4-b5c6-4d7e-8f90-aabbccddeeff"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "422":
          description: Category depth cap (4) exceeded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      error:
                        type: string
                        enum: [depth_exceeded]
                      max_depth:
                        type: integer
              example:
                data:
                  error: depth_exceeded
                  max_depth: 4
        "429":
          $ref: '#/components/responses/RateLimited'
  /kb/categories/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Category ID
        schema:
          type: string
          format: uuid
    put:
      x-stability: stable
      x-required-scope: kb:write
      summary: Update a knowledge base category
      description: |
        Updates name and sort order only — reparenting is not supported.

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:write` scope.
      operationId: updateKbCategory
      tags: [Knowledge Base]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                sort_order:
                  type: integer
                  default: 0
            example:
              name: "VPN & Remote Access"
              sort_order: 1
      responses:
        "200":
          description: Category updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
              example:
                data:
                  id: "d1f2e3a4-b5c6-4d7e-8f90-aabbccddeeff"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
    delete:
      x-stability: stable
      x-required-scope: kb:write
      summary: Delete a knowledge base category
      description: |
        Deletes the category. Child categories cascade-delete; articles in
        deleted categories keep existing with their category cleared
        (`ON DELETE SET NULL`).

        Requires an MSP staff user on a PSA or Bundle tier tenant in addition
        to the `kb:write` scope.
      operationId: deleteKbCategory
      tags: [Knowledge Base]
      responses:
        "204":
          description: Category deleted
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /licenses:
    get:
      x-stability: stable
      x-required-scope: licenses:read
      summary: List software licences
      description: |
        Returns all software licences for the tenant ordered by product
        name, each enriched with joined client/site names, live
        assigned_count, and derived usage_status. Response `data` is a
        bare array — no pagination.
      operationId: listLicenses
      tags: [Licenses]
      responses:
        "200":
          description: Licence list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: *SoftwareLicense
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: stable
      x-required-scope: licenses:write
      summary: Create a software licence
      description: |
        Creates a licence record. `license_type` defaults to `perpetual`;
        `warn_days` defaults to `[30, 14, 7]` when omitted. When
        `match_pattern` is set, auto-matching against agent software
        inventories runs in the background after creation.
      operationId: createLicense
      tags: [Licenses]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_name, quantity]
              properties:
                product_name:
                  type: string
                  maxLength: 255
                publisher:
                  type: string
                  maxLength: 255
                license_key: {type: string}
                license_type:
                  type: string
                  enum: [perpetual, subscription, volume, oem]
                  default: perpetual
                quantity:
                  type: integer
                  minimum: 1
                assigned_client_id:
                  type: string
                  format: uuid
                  nullable: true
                assigned_site_id:
                  type: string
                  format: uuid
                  nullable: true
                purchase_date:
                  type: string
                  nullable: true
                  description: YYYY-MM-DD
                expiry_date:
                  type: string
                  nullable: true
                  description: YYYY-MM-DD
                cost:
                  type: number
                  nullable: true
                notes: {type: string}
                match_pattern:
                  type: string
                  maxLength: 200
                  description: Case-insensitive regex matched against software inventory names
                warn_days:
                  type: array
                  items: {type: integer, minimum: 1}
      responses:
        "201":
          description: Licence created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "422":
          description: Validation failure (quantity < 1, bad regex, unknown license_type, ...)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /licenses/import:
    post:
      x-stability: stable
      x-required-scope: licenses:write
      summary: Import licences from CSV
      description: |
        Multipart upload (`file` field, max 10 MB) of a CSV with header
        row and columns product_name, publisher, license_key,
        expiry_date, quantity, match_pattern, notes. Rows upsert on
        (product_name, publisher). Per-row failures are reported in
        `errors` without aborting the import.
      operationId: importLicensesCsv
      tags: [Licenses]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Import outcome
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      imported:
                        type: integer
                        description: Rows inserted
                      updated:
                        type: integer
                        description: Rows that matched an existing licence
                      errors:
                        type: array
                        items:
                          type: object
                          properties:
                            row:
                              type: integer
                              description: 1-indexed CSV row number
                            message: {type: string}
        "400":
          description: Missing/unparseable file or fewer than two rows
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /licenses/summary:
    get:
      x-stability: stable
      x-required-scope: licenses:read
      summary: Licence usage summary
      description: |
        Aggregate stats across the tenant's licences: total count,
        licences expiring within 30 days, over-licensed count, and
        overall seat usage percentage (capped at 100).
      operationId: getLicenseSummary
      tags: [Licenses]
      responses:
        "200":
          description: Summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total_licenses: {type: integer}
                      expiring_soon: {type: integer}
                      over_licensed: {type: integer}
                      usage_percent: {type: number}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /licenses/{licenseID}:
    get:
      x-stability: stable
      x-required-scope: licenses:read
      summary: Get a software licence
      operationId: getLicense
      tags: [Licenses]
      parameters:
        - name: licenseID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Licence detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SoftwareLicense
        "400":
          description: Invalid licence ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: stable
      x-required-scope: licenses:write
      summary: Update a software licence
      description: |
        Partial update — only fields present in the body are changed.
        Returns `{status: "updated"}`, not the updated row. When
        `match_pattern` is updated to a non-empty value, auto-matching
        re-runs in the background.
      operationId: updateLicense
      tags: [Licenses]
      parameters:
        - name: licenseID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                product_name:
                  type: string
                  maxLength: 255
                publisher:
                  type: string
                  maxLength: 255
                license_key: {type: string}
                license_type:
                  type: string
                  enum: [perpetual, subscription, volume, oem]
                quantity:
                  type: integer
                  minimum: 1
                assigned_client_id:
                  type: string
                  format: uuid
                  nullable: true
                assigned_site_id:
                  type: string
                  format: uuid
                  nullable: true
                purchase_date:
                  type: string
                  nullable: true
                  description: YYYY-MM-DD
                expiry_date:
                  type: string
                  nullable: true
                  description: YYYY-MM-DD
                cost:
                  type: number
                  nullable: true
                notes: {type: string}
                match_pattern:
                  type: string
                  maxLength: 200
                warn_days:
                  type: array
                  items: {type: integer, minimum: 1}
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [updated]
        "400":
          description: Invalid licence ID or body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "422":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      x-stability: stable
      x-required-scope: licenses:write
      summary: Delete a software licence
      operationId: deleteLicense
      tags: [Licenses]
      parameters:
        - name: licenseID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "204":
          description: Deleted (no body)
        "400":
          description: Invalid licence ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /licenses/{licenseID}/assignments:
    get:
      x-stability: stable
      x-required-scope: licenses:read
      summary: List agent assignments for a licence
      description: |
        Returns every agent assigned to the licence (newest first) with
        the joined agent hostname. Response `data` is a bare array.
      operationId: listLicenseAssignments
      tags: [Licenses]
      parameters:
        - name: licenseID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Assignment list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        license_id: {type: string, format: uuid}
                        agent_id: {type: string, format: uuid}
                        agent_name:
                          type: string
                          description: Joined agent hostname ("" when the agent row is gone)
                        assigned_at: {type: string, format: date-time}
                        source:
                          type: string
                          enum: [auto, manual]
        "400":
          description: Invalid licence ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: stable
      x-required-scope: licenses:write
      summary: Assign an agent to a licence
      description: Creates a manual (source=manual) licence assignment.
      operationId: createLicenseAssignment
      tags: [Licenses]
      parameters:
        - name: licenseID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_id]
              properties:
                agent_id: {type: string, format: uuid}
      responses:
        "201":
          description: Assignment created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
        "400":
          description: Invalid licence ID or body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "422":
          description: agent_id missing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /licenses/{licenseID}/assignments/{agentID}:
    delete:
      x-stability: stable
      x-required-scope: licenses:write
      summary: Remove an agent from a licence
      description: |
        Deletes the licence/agent assignment. Returns 204 whether or not
        an assignment row existed (the delete is idempotent).
      operationId: deleteLicenseAssignment
      tags: [Licenses]
      parameters:
        - name: licenseID
          in: path
          required: true
          schema: {type: string, format: uuid}
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "204":
          description: Removed (no body)
        "400":
          description: Invalid licence or agent ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /licenses/{licenseID}/match:
    post:
      x-stability: stable
      x-required-scope: licenses:write
      summary: Re-run auto-matching for a licence
      description: |
        Deletes existing source=auto assignments and re-matches agents
        via software inventory: case-insensitive regex on
        `match_pattern` when set, otherwise an exact case-insensitive
        match on `product_name`. Manual assignments are untouched.
      operationId: matchLicense
      tags: [Licenses]
      parameters:
        - name: licenseID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Match outcome
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      matched:
                        type: integer
                        description: Number of auto assignments created
        "400":
          description: Invalid licence ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /login:
    post:
      x-stability: internal
      summary: Authenticate and obtain a bearer token
      description: |
        Accepts username OR email as the identifier (username matches win;
        an email shared across tenants without a username match is rejected
        as ambiguous with a generic 401). Returns a 24h bearer token, or —
        when TOTP MFA is enabled — `{requires_mfa: true, mfa_token}` for
        completion via POST /mfa/login.

        Turnstile step-up: clean logins never see a bot check. Once the
        identifier has accumulated repeated failures, every further attempt
        must carry `cf_turnstile_response`. Contract: 428 CHALLENGE_REQUIRED
        (render the widget and resubmit with a token), 400 BOT_CHECK_FAILED
        (token rejected — reset and retry), 503 BOT_CHECK_UNAVAILABLE
        (verifier unreachable — retry later).
      operationId: login
      security: []
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username:
                  type: string
                  description: Username or email address
                password: {type: string}
                cf_turnstile_response:
                  type: string
                  description: Turnstile token; only required after the 428 step-up challenge
      responses:
        "200":
          description: Bearer token issued, or MFA challenge
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      token:
                        type: string
                        description: Bearer token (24h TTL); absent when MFA is required
                      user: &AuthUser
                        type: object
                        description: Authenticated user incl. tenant subscription context
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          username: {type: string}
                          email: {type: string, format: email}
                          role_id: {type: string, format: uuid, nullable: true}
                          is_superuser: {type: boolean}
                          is_active: {type: boolean}
                          user_type:
                            type: string
                            description: "msp_staff or portal user type; drives the SPA routing guard"
                          contact_id: {type: string, format: uuid, nullable: true}
                          last_login: {type: string, format: date-time, nullable: true}
                          created_at: {type: string, format: date-time}
                          updated_at: {type: string, format: date-time}
                          billing_status: {type: string, description: "Subscription billing status (e.g. trialing, active, trial_expired); omitted when no subscription row"}
                          tier: {type: string, description: Subscription tier; omitted when no subscription row}
                          trial_end: {type: string, format: date-time, nullable: true}
                          is_platform_admin: {type: boolean, description: Omitted when false}
                      requires_mfa:
                        type: boolean
                        description: "true = complete login via POST /mfa/login with mfa_token"
                      mfa_token:
                        type: string
                        description: Short-lived MFA continuation token (only when requires_mfa)
        "400":
          description: Missing fields (VALIDATION_ERROR) or Turnstile token rejected (BOT_CHECK_FAILED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Invalid credentials, disabled account, or ambiguous email (always AUTH_FAILED — no enumeration)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "423":
          description: Account temporarily locked by brute-force protection (ACCOUNT_LOCKED); Retry-After header holds the RFC3339 unlock time
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "428":
          description: Step-up bot check required (CHALLENGE_REQUIRED) — resubmit with cf_turnstile_response
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Turnstile verifier unreachable/misconfigured (BOT_CHECK_UNAVAILABLE) — retry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /logout:
    post:
      x-stability: internal
      summary: Invalidate the current bearer token
      description: |
        Deletes the presented session token. Session-token only — API keys are
        refused (403 API_KEY_FORBIDDEN): a key must not be able to kill its
        owner's sessions.
      operationId: logout
      tags: [Auth]
      responses:
        "204":
          description: Token invalidated
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /mdm/amapi/webhook:
    post:
      x-stability: internal
      operationId: receiveAMAPIWebhook
      summary: Google AMAPI Pub/Sub push webhook
      description: |
        Inbound endpoint for Google Cloud Pub/Sub push notifications from the
        Android Management API. Every delivery is first audited raw to
        mdm_device_events. When the AMAPI provider is configured, the
        `Authorization: Bearer <JWT>` header (a Google-signed Pub/Sub push
        OIDC token) is verified against this endpoint's URL as audience; a
        verification failure is 401, and parsed device events are dispatched
        to the compliance service. When no provider is wired the endpoint is
        audit-only and always returns 200. Body capped at 2 MiB.
      tags: ["Webhooks (Inbound)"]
      security: []
      parameters:
        - name: Authorization
          in: header
          required: false
          description: Google-signed Pub/Sub push OIDC bearer token; verified only when the AMAPI provider is configured.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Pub/Sub push envelope wrapping an AMAPI device notification.
      responses:
        "200":
          description: Event audited (and processed when a provider is configured).
        "400":
          description: Body read failure or exceeded the 2 MiB cap.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          description: Pub/Sub push JWT verification failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/apple/checkin:
    post:
      x-stability: internal
      operationId: receiveAppleMDMCheckin
      summary: Apple MDM CheckIn endpoint
      description: |
        Inbound endpoint for Apple MDM CheckIn messages. Currently audit-only
        (Apple MDM is Phase 60c, parked): the raw payload is written to
        mdm_device_events with `processed_at` NULL and 200 is returned. NO
        signature verification yet (planned for Wave 4) — the `X-Signature`
        or `Authorization` header value is stored alongside the payload for
        later verification. Body capped at 2 MiB.
      tags: ["Webhooks (Inbound)"]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: 'Apple MDM CheckIn plist/JSON payload (stored verbatim; non-JSON bodies are wrapped as `{"raw": ...}`).'
      responses:
        "200":
          description: Payload audited.
        "400":
          description: Body read failure or exceeded the 2 MiB cap.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/apple/server:
    post:
      x-stability: internal
      operationId: receiveAppleMDMServer
      summary: Apple MDM CommandResults endpoint
      description: |
        Inbound endpoint for Apple MDM CommandResults (server channel).
        Identical posture to `/mdm/apple/checkin`: audit-only until Wave 4
        signature verification — raw payload stored to mdm_device_events,
        `X-Signature`/`Authorization` header captured, always 200. Body capped
        at 2 MiB.
      tags: ["Webhooks (Inbound)"]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: 'Apple MDM CommandResults payload (stored verbatim; non-JSON bodies are wrapped as `{"raw": ...}`).'
      responses:
        "200":
          description: Payload audited.
        "400":
          description: Body read failure or exceeded the 2 MiB cap.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/connect/amapi/redirect:
    get:
      x-stability: internal
      operationId: amapiConnectRedirect
      summary: AMAPI enterprise-signup callback redirect
      description: |
        Public landing endpoint registered with Google as the AMAPI enterprise
        signup callback. Google redirects the operator's browser here with
        `?enterpriseToken=...`; because the SPA uses hash history, this server
        route 302s into the SPA hash route
        (`/#/settings/mdm/connect/amapi/callback`), preserving the token. No
        auth: it only forwards an opaque token into the SPA, which completes
        the bind under the operator's own session.
      tags: ["Webhooks (Inbound)"]
      security: []
      parameters:
        - name: enterpriseToken
          in: query
          required: false
          description: Opaque enterprise signup token issued by Google; forwarded into the SPA callback route.
          schema:
            type: string
      responses:
        "302":
          description: Redirect to the SPA AMAPI callback hash route with the token preserved.
          headers:
            Location:
              schema:
                type: string
  /mdm/connections:
    get:
      x-stability: beta
      x-required-scope: mdm:read
      summary: List MDM provider connections
      description: |
        Returns one row per provider (e.g. `google_amapi`) with its binding
        status. Secrets (`credentials_encrypted`, `signup_url_name`) are never
        exposed. Not paginated — returns the full set (at most one row per
        provider). Requires the per-org MDM beta flag; routes 404 when it is
        off.
      operationId: listMdmConnections
      tags: [MDM]
      responses:
        "200":
          description: Connection list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        provider:
                          type: string
                          example: google_amapi
                        status:
                          type: string
                          enum: [pending, connected, disconnected]
                        external_enterprise_id:
                          type: string
                          description: Bound AMAPI enterprise resource name (omitted when empty)
                          example: enterprises/LC02abc123
                        last_sync_at:
                          type: string
                          format: date-time
                          nullable: true
                        last_sync_error:
                          type: string
                          description: Last provider error recorded by the sync engine (omitted when empty)
              example:
                data:
                  - provider: google_amapi
                    status: connected
                    external_enterprise_id: enterprises/LC02abc123
                    last_sync_at: "2026-06-10T09:14:00Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/connections/{provider}/callback:
    parameters:
      - name: provider
        in: path
        required: true
        description: MDM provider key. Only `google_amapi` is live; `apple_mdm` returns 501.
        schema:
          type: string
          enum: [google_amapi, apple_mdm]
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Complete the AMAPI enterprise binding (connect leg 2)
      description: |
        Exchanges the `enterpriseToken` Google handed the operator's browser
        (forwarded by the SPA callback page) for a bound enterprise, persists
        the enterprise id, and flips the connection to `connected`. Requires a
        pending signup created by the connect leg; otherwise 409
        `NO_PENDING_SIGNUP`. A provider failure leaves the row pending so the
        callback can be retried (502 `PROVIDER_ERROR`).
      operationId: completeMdmConnect
      tags: [MDM]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enterprise_token]
              properties:
                enterprise_token:
                  type: string
                  description: Opaque token from Google's signup redirect
            example:
              enterprise_token: EAEXAMPLEtokenFromGoogleRedirect
      responses:
        "200":
          description: Enterprise bound; connection is now connected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: connected
                      external_enterprise_id:
                        type: string
                        example: enterprises/LC02abc123
        "400":
          description: Missing enterprise_token or unknown provider
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: No pending signup to complete (NO_PENDING_SIGNUP)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "501":
          description: Apple MDM is not available yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provider enterprise binding failed (row stays pending; retry)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: MDM is not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/connections/{provider}/connect:
    parameters:
      - name: provider
        in: path
        required: true
        description: MDM provider key. Only `google_amapi` is live; `apple_mdm` returns 501.
        schema:
          type: string
          enum: [google_amapi, apple_mdm]
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Start the AMAPI enterprise binding (connect leg 1)
      description: |
        Mints a Google-hosted enterprise signup URL and marks the connection
        `pending`. The operator opens the URL, completes Google's signup, and
        the browser is redirected back; the SPA then POSTs the resulting
        token to the callback endpoint (leg 2). An already-connected provider
        returns 409 `ALREADY_CONNECTED`; a pending signup may be reissued.
        No request body.
      operationId: startMdmConnect
      tags: [MDM]
      responses:
        "200":
          description: Signup URL minted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      signup_url:
                        type: string
                        example: https://enterprise.google.com/signup/android/email?token=EXAMPLE
        "400":
          description: Unknown provider
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Provider is already connected (ALREADY_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "501":
          description: Apple MDM is not available yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provider signup failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: MDM is not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/connections/{provider}/disconnect:
    parameters:
      - name: provider
        in: path
        required: true
        description: MDM provider key. Only `google_amapi` is live; `apple_mdm` returns 501.
        schema:
          type: string
          enum: [google_amapi, apple_mdm]
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Disconnect the MDM provider (DESTRUCTIVE)
      description: |
        Tears down the enterprise binding. **Destructive**: AMAPI deletes the
        enterprise, which wipes every enrolled device's work profile. The
        caller must echo the exact enterprise id in `confirm_enterprise_id`
        as a typed-confirm guard, or the request fails with 422
        `CONFIRM_MISMATCH`. On success all of the org's devices for the
        provider are marked `released` and taken off billing.
      operationId: disconnectMdmProvider
      tags: [MDM]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm_enterprise_id]
              properties:
                confirm_enterprise_id:
                  type: string
                  description: Must exactly match the connected enterprise id
            example:
              confirm_enterprise_id: enterprises/LC02abc123
      responses:
        "200":
          description: Provider disconnected; devices released
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: disconnected
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: No connection to disconnect, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Connection is not in a connected state (NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: confirm_enterprise_id does not match (CONFIRM_MISMATCH)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "501":
          description: Apple MDM is not available yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provider disconnect failed (retry to converge)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: MDM is not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/connections/{provider}/test:
    parameters:
      - name: provider
        in: path
        required: true
        description: MDM provider key. Only `google_amapi` is live; `apple_mdm` returns 501.
        schema:
          type: string
          enum: [google_amapi, apple_mdm]
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Test the MDM provider connection
      description: |
        Side-effect-free health check: mints (and discards) a short-lived
        Google signup URL. Always returns 200 with `{ok, error?}` rather than
        an HTTP error so the raw provider diagnostic (API not enabled, bad
        scope, billing off, …) reaches the operator intact. No request body.
      operationId: testMdmConnection
      tags: [MDM]
      responses:
        "200":
          description: Test result (ok=false carries the provider error)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok:
                        type: boolean
                      error:
                        type: string
                        description: Raw provider error (present only when ok=false)
              example:
                data:
                  ok: false
                  error: "googleapi: Error 403: Android Management API has not been used in project 1234 before or it is disabled"
        "400":
          description: Unknown provider
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "501":
          description: Apple MDM is not available yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: MDM is not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/defaults:
    get:
      x-stability: beta
      x-required-scope: mdm:read
      summary: Get hierarchy default policies
      description: |
        Returns the org-wide default policy id and every site's default
        (org -> site -> device hierarchy; a device-level override wins, then
        the site default, then the org default). Not paginated — returns all
        sites ordered by client then site name.
      operationId: getMdmDefaults
      tags: [MDM]
      responses:
        "200":
          description: Org and per-site defaults
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      org_default_policy_id:
                        type: string
                        format: uuid
                        nullable: true
                      sites:
                        type: array
                        items:
                          type: object
                          properties:
                            site_id:
                              type: string
                              format: uuid
                            site_name:
                              type: string
                            client_name:
                              type: string
                            default_policy_id:
                              type: string
                              format: uuid
                              nullable: true
              example:
                data:
                  org_default_policy_id: 7e9c1a2e-1f7c-4f6e-9d2a-0b9f4c1d8e21
                  sites:
                    - site_id: 2d6f3b1a-9c44-4e0a-8a3f-5e7d2c9b1a40
                      site_name: Head Office
                      client_name: Acme Engineering
                      default_policy_id: null
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/defaults/org:
    put:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Set the org default policy
      description: |
        Sets (or clears, with a null/empty `policy_id`) the org-wide default
        MDM policy, then best-effort propagates the resolved effective policy
        to every enrolled device at the provider. The DB write always
        commits; `reconciled=false` in the response means propagation hit a
        provider problem and the operator should retry or re-save.
      operationId: setMdmOrgDefault
      tags: [MDM]
      requestBody:
        required: true
        content:
          application/json:
            schema: &MDMDefaultPolicyRequest
              type: object
              properties:
                policy_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Policy to set as default; null or empty string clears it
            example:
              policy_id: 7e9c1a2e-1f7c-4f6e-9d2a-0b9f4c1d8e21
      responses:
        "200":
          description: Default saved; reconciled reports propagation success
          content:
            application/json:
              schema: &MDMReconcileResponse
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: ok
                      reconciled:
                        type: boolean
                        description: false when provider propagation failed or MDM is not connected
        "400":
          description: Invalid or non-existent policy_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/defaults/site/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Site ID
        schema:
          type: string
          format: uuid
    put:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Set a site default policy
      description: |
        Sets (or clears) one site's default MDM policy and propagates, same
        semantics as the org default. 404 when the site is not in the org.
      operationId: setMdmSiteDefault
      tags: [MDM]
      requestBody:
        required: true
        content:
          application/json:
            schema: *MDMDefaultPolicyRequest
            example:
              policy_id: null
      responses:
        "200":
          description: Default saved; reconciled reports propagation success
          content:
            application/json:
              schema: *MDMReconcileResponse
        "400":
          description: Invalid site id or invalid/non-existent policy_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Site not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/devices:
    get:
      x-stability: beta
      x-required-scope: mdm:read
      summary: List MDM devices
      description: |
        Returns every enrolled device for the organisation, most recently
        seen first. Not paginated and no query filters — the full inventory
        is returned in one response. `raw_state` is omitted on the list; use
        the single-device GET for the provider document.
      operationId: listMdmDevices
      tags: [MDM]
      responses:
        "200":
          description: Device list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &MDMDevice
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        site_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Operator-assigned site (omitted when unassigned)
                        provider:
                          type: string
                          example: google_amapi
                        external_id:
                          type: string
                          description: Provider device resource name
                          example: enterprises/LC02abc123/devices/3f1a2b3c4d5e6f70
                        serial:
                          type: string
                          example: R52T80ABCDE
                        model:
                          type: string
                          example: Galaxy Tab A9
                        manufacturer:
                          type: string
                          example: Samsung
                        os:
                          type: string
                          example: Android
                        os_version:
                          type: string
                          example: "14"
                        ownership:
                          type: string
                          enum: [company, personal, unknown]
                        enrolment_state:
                          type: string
                          enum: [pending, enrolled, compliant, noncompliant, lost, wiped, released]
                        last_seen_at:
                          type: string
                          format: date-time
                          nullable: true
                        last_sync_at:
                          type: string
                          format: date-time
                          nullable: true
                        policy_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Device-level policy override (omitted when inheriting)
                        is_billable:
                          type: boolean
                          description: true while enrolled/compliant/noncompliant (counts toward asset billing)
                        raw_state:
                          type: object
                          nullable: true
                          description: Provider's last-synced device document (AMAPI device resource). Only populated on the single-device GET; omitted on the list.
              example:
                data:
                  - id: 5b1f0c9e-8a3d-4f21-b7c6-2e9d8a1f0c3b
                    site_id: 2d6f3b1a-9c44-4e0a-8a3f-5e7d2c9b1a40
                    provider: google_amapi
                    external_id: enterprises/LC02abc123/devices/3f1a2b3c4d5e6f70
                    serial: R52T80ABCDE
                    model: Galaxy Tab A9
                    manufacturer: Samsung
                    os: Android
                    os_version: "14"
                    ownership: company
                    enrolment_state: compliant
                    last_seen_at: "2026-06-10T08:55:12Z"
                    last_sync_at: "2026-06-10T09:00:00Z"
                    policy_id: 7e9c1a2e-1f7c-4f6e-9d2a-0b9f4c1d8e21
                    is_billable: true
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/devices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: MDM device ID
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: mdm:read
      summary: Get an MDM device
      description: |
        Returns one device including `raw_state` (the provider's last-synced
        device document) so a client can render apps/location/compliance
        detail without extra calls.
      operationId: getMdmDevice
      tags: [MDM]
      responses:
        "200":
          description: Device detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *MDMDevice
        "400":
          description: Invalid device id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Device not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/devices/{id}/clear-lost:
    parameters:
      - name: id
        in: path
        required: true
        description: MDM device ID
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Clear lost mode (recovery)
      description: |
        Recovery counterpart of report-lost. Provider-first: issues the
        `lost_mode_off` command, and only when the remote unlock succeeds
        flips the device back to a billable `enrolled` state and resolves the
        critical lost-device alert. If the unlock fails the device stays
        `lost` (502) and the operator can retry. Only a device currently in
        `lost` state can be cleared (409 `NOT_LOST`). The incident ticket is
        left open for the operator. No request body.
      operationId: clearMdmDeviceLost
      tags: [MDM]
      responses:
        "200":
          description: Device unlocked and recovered
          content:
            application/json:
              schema: &MDMLostResponse
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      lock_issued:
                        type: boolean
                        description: Whether the remote lock/unlock command reached the provider
                      lock_error:
                        type: string
                        description: Provider error when lock_issued=false (report-lost only)
                      ticket_id:
                        type: string
                        format: uuid
                        description: Incident ticket raised (report-lost only)
                      ticket_error:
                        type: string
                        description: Set when the incident ticket could not be created (report-lost only)
              example:
                data:
                  lock_issued: true
        "400":
          description: Invalid device id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Device not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Device is not in lost mode (NOT_LOST)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provider unlock failed; device left locked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: MDM is not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/devices/{id}/commands:
    parameters:
      - name: id
        in: path
        required: true
        description: MDM device ID
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: mdm:read
      summary: List a device's command history
      description: |
        Returns the device's most recent commands, newest first, capped at 50
        rows. No paging parameters — older history beyond the cap is not
        retrievable via the API.
      operationId: listMdmDeviceCommands
      tags: [MDM]
      responses:
        "200":
          description: Command history (most recent 50)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        command:
                          type: string
                          enum: [lock, wipe, reboot, sync_policy, lost_mode_on, lost_mode_off, reset_password]
                        status:
                          type: string
                          enum: [queued, sent, acknowledged, failed]
                        queued_at:
                          type: string
                          format: date-time
                        sent_at:
                          type: string
                          format: date-time
                          nullable: true
                        completed_at:
                          type: string
                          format: date-time
                          nullable: true
              example:
                data:
                  - id: 9c3e7d51-2a4b-4c8f-a1e0-6f5d4b3c2a19
                    command: lock
                    status: sent
                    queued_at: "2026-06-10T10:02:00Z"
                    sent_at: "2026-06-10T10:02:01Z"
        "400":
          description: Invalid device id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Issue a device command
      description: |
        Fires a command at the device and writes an audit row before the
        provider call. Destructive commands (`wipe`, `reset_password`)
        require `confirm: true` or the request fails with 422
        `CONFIRM_REQUIRED`. `reset_password` accepts an optional
        `new_password`. `sync_policy` is not issuable here — push the policy
        instead.
      operationId: issueMdmDeviceCommand
      tags: [MDM]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [command]
              properties:
                command:
                  type: string
                  enum: [lock, reboot, reset_password, lost_mode_on, lost_mode_off, wipe]
                new_password:
                  type: string
                  description: Optional new passcode for reset_password
                confirm:
                  type: boolean
                  description: Must be true for wipe and reset_password
            example:
              command: wipe
              confirm: true
      responses:
        "200":
          description: Command issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                        description: Audit row id
                      status:
                        type: string
                        example: sent
        "400":
          description: Invalid body, device id, or unknown/unsupported command
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Device not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: Destructive command without confirm=true (CONFIRM_REQUIRED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provider command failed (audit row marked failed)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: MDM is not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/devices/{id}/policy:
    parameters:
      - name: id
        in: path
        required: true
        description: MDM device ID
        schema:
          type: string
          format: uuid
    put:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Assign a policy to a device
      description: |
        Points a device at a policy: pushes the policy to the provider
        (idempotent), assigns it on the device at the provider, then records
        the link locally as the device-level override (which wins over
        site/org defaults). Requires a connected Android MDM binding (409
        `NOT_CONNECTED`).
      operationId: assignMdmDevicePolicy
      tags: [MDM]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [policy_id]
              properties:
                policy_id:
                  type: string
                  format: uuid
            example:
              policy_id: 7e9c1a2e-1f7c-4f6e-9d2a-0b9f4c1d8e21
      responses:
        "200":
          description: Policy assigned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: assigned
        "400":
          description: Invalid body, device id, or policy_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Device or policy not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Android MDM is not connected (NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provider policy push or device assignment failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/devices/{id}/report-lost:
    parameters:
      - name: id
        in: path
        required: true
        description: MDM device ID
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Report a device lost
      description: |
        Lost-device automation in one action: (1) best-effort remote lock via
        AMAPI Lost Mode, (2) the device is marked `lost` and comes off
        billing immediately, (3) a critical alert is raised, and (4) a PSA
        incident ticket is created (deduped per device). The remote lock is
        best-effort — if the device is unreachable the response still
        succeeds with `lock_issued=false` and `lock_error` set, because the
        paper trail matters most exactly then. Devices that have already
        left the fleet (released/wiped) return 409 `OFF_FLEET`. No request
        body.
      operationId: reportMdmDeviceLost
      tags: [MDM]
      responses:
        "200":
          description: Device marked lost (lock and ticket results inside)
          content:
            application/json:
              schema: *MDMLostResponse
              example:
                data:
                  lock_issued: true
                  ticket_id: 4f8a2c6e-1b3d-4e5f-9a0c-7d6e5f4a3b2c
        "400":
          description: Invalid device id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Device not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Device has left the fleet and cannot be reported lost (OFF_FLEET)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: MDM is not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/enrolment-tokens:
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Create a device enrolment token
      description: |
        Mints an AMAPI enrolment token for the org's connected enterprise and
        returns a server-rendered QR data URL plus the manual-entry value for
        the Android setup wizard. Body is optional; `ttl_minutes` is clamped
        to 5–1440 (default 60). Maps to the `mdm.enrol` permission, which
        shares the `mdm:write` API-key scope. Requires a connected Android
        binding (409 `NOT_CONNECTED`).
      operationId: createMdmEnrolmentToken
      tags: [MDM]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ttl_minutes:
                  type: integer
                  description: Token lifetime in minutes (clamped to 5-1440; default 60)
                  example: 60
      responses:
        "200":
          description: Enrolment token minted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      qr_image:
                        type: string
                        description: PNG data URL of the enrolment QR (empty string if rendering failed; use value instead)
                        example: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
                      value:
                        type: string
                        description: Manual-entry token for the Android setup wizard
                        example: XKWFNZUDQGAMPLE
                      expires_at:
                        type: string
                        format: date-time
                        example: "2026-06-10T11:00:00Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Android MDM is not connected for this organisation (NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provider enrolment failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: MDM is not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/managed-play/web-token:
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Mint a Managed Google Play web token
      description: |
        Mints an app-picker iframe token for the connected enterprise, scoped
        to this cell's SPA origin, so the operator can browse/approve Managed
        Play apps inside the policy editor. Requires a connected Android
        binding (409 `NOT_CONNECTED`). No request body.
      operationId: createMdmManagedPlayToken
      tags: [MDM]
      responses:
        "200":
          description: Web token minted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      token:
                        type: string
                        description: Opaque iframe token for the Managed Play embed
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Android MDM is not connected (NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provider token request failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: MDM is not configured on this server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/policies:
    get:
      x-stability: beta
      x-required-scope: mdm:read
      summary: List MDM policies
      description: |
        Returns the org's policies as list rows (no kind detail; use the
        single-policy GET). Not paginated — the full set is returned, ordered
        by name. `kind_count` counts only enabled kinds.
      operationId: listMdmPolicies
      tags: [MDM]
      responses:
        "200":
          description: Policy list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        name:
                          type: string
                          example: Field tablets - kiosk
                        description:
                          type: string
                          description: Omitted when empty
                        active:
                          type: boolean
                        kind_count:
                          type: integer
                          description: Number of enabled policy kinds
                          example: 3
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Create an MDM policy
      description: |
        Inserts a policy with its kind configurations. `name` is required and
        every kind must be one of the known policy kinds (400 otherwise).
        Creating a policy does not push it to the provider — use the push
        endpoint or assign it to a device/default.
      operationId: createMdmPolicy
      tags: [MDM]
      requestBody:
        required: true
        content:
          application/json:
            schema: &MDMPolicyUpsert
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  example: Field tablets - kiosk
                description:
                  type: string
                  example: Locked-down kiosk profile for warehouse scanners
                active:
                  type: boolean
                kinds:
                  type: array
                  description: Replaces the policy's kinds wholesale on save
                  items:
                    type: object
                    properties:
                      kind:
                        type: string
                        enum: [passcode, encryption, app_whitelist, location, lost_mode, wifi, os_update, kiosk]
                      config:
                        type: object
                        description: Kind-specific configuration document
                      enabled:
                        type: boolean
            example:
              name: Field tablets - kiosk
              description: Locked-down kiosk profile for warehouse scanners
              active: true
              kinds:
                - kind: passcode
                  config: {min_length: 6}
                  enabled: true
                - kind: kiosk
                  config: {app: "com.example.scanner"}
                  enabled: true
      responses:
        "201":
          description: Policy created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
        "400":
          description: Missing name or unknown policy kind
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: MDM beta is not enabled for this organisation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/policies/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Policy ID
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: mdm:read
      summary: Get an MDM policy
      description: Returns the full editable policy including all kind configurations.
      operationId: getMdmPolicy
      tags: [MDM]
      responses:
        "200":
          description: Policy detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      name:
                        type: string
                      description:
                        type: string
                        description: Omitted when empty
                      active:
                        type: boolean
                      kinds:
                        type: array
                        items:
                          type: object
                          properties:
                            kind:
                              type: string
                              enum: [passcode, encryption, app_whitelist, location, lost_mode, wifi, os_update, kiosk]
                            config:
                              type: object
                            enabled:
                              type: boolean
        "400":
          description: Invalid policy id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Policy not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    put:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Update an MDM policy
      description: |
        Replaces the policy header and its kinds wholesale, then best-effort
        re-propagates to devices already using this policy (directly or via a
        site/org default). `reconciled=false` means a provider hiccup during
        propagation; the saved edit is not rolled back.
      operationId: updateMdmPolicy
      tags: [MDM]
      requestBody:
        required: true
        content:
          application/json:
            schema: *MDMPolicyUpsert
      responses:
        "200":
          description: Policy updated; reconciled reports propagation success
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: updated
                      reconciled:
                        type: boolean
        "400":
          description: Invalid id/body, missing name, or unknown policy kind
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Policy not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Delete an MDM policy
      description: |
        Removes the policy locally (kinds cascade) and best-effort deletes
        the materialised policy at the provider; a leftover empty provider
        policy is harmless and does not fail the request.
      operationId: deleteMdmPolicy
      tags: [MDM]
      responses:
        "200":
          description: Policy deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: deleted
        "400":
          description: Invalid policy id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Policy not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mdm/policies/{id}/push:
    parameters:
      - name: id
        in: path
        required: true
        description: Policy ID
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: mdm:write
      summary: Push a policy to the provider
      description: |
        Materialises the canonical policy at the provider (AMAPI). Idempotent.
        Requires a connected Android binding (409 `NOT_CONNECTED`). No
        request body.
      operationId: pushMdmPolicy
      tags: [MDM]
      responses:
        "200":
          description: Policy pushed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        example: pushed
        "400":
          description: Invalid policy id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Policy not found, or MDM beta not enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Android MDM is not connected (NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Provider policy push failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /mfa/disable:
    post:
      x-stability: internal
      summary: Disable MFA for the current user
      description: |
        Requires a current TOTP `code` OR a `backup_code` to confirm. Clears
        the secret and all backup codes. Session-token only (API keys refused).
      operationId: mfaDisable
      tags: [MFA]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Exactly one of code / backup_code is required
              properties:
                code: {type: string, description: Current 6-digit TOTP code}
                backup_code: {type: string, description: Unused backup code}
      responses:
        "204":
          description: MFA disabled
        "400":
          description: Neither code supplied, invalid code (INVALID_CODE), or MFA not enabled (MFA_NOT_ENABLED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /mfa/login:
    post:
      x-stability: internal
      summary: Complete MFA login
      description: |
        Exchanges the short-lived `mfa_token` from POST /login plus a TOTP
        `code` OR a single-use `backup_code` for a real bearer token. Used
        backup codes are burned. Rate-limited like /login.
      operationId: mfaLogin
      security: []
      tags: [MFA]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mfa_token]
              properties:
                mfa_token:
                  type: string
                  description: Temporary token from the initial login response
                code: {type: string, description: 6-digit TOTP code}
                backup_code: {type: string, description: Backup code (alternative to code)}
      responses:
        "200":
          description: MFA verified; bearer token returned
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      token: {type: string}
                      user: *AuthUser
        "400":
          description: Missing mfa_token or neither code supplied (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Invalid/expired MFA token (MFA_TOKEN_INVALID), wrong code (INVALID_CODE), or disabled account (AUTH_DISABLED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
  /mfa/setup:
    post:
      x-stability: internal
      summary: Begin TOTP MFA setup
      description: |
        Generates and stores a TOTP secret (not yet enabled) and returns the
        secret plus the otpauth:// provisioning URL for QR rendering. Complete
        with POST /mfa/verify-setup. Session-token only (API keys refused).
      operationId: mfaSetup
      tags: [MFA]
      responses:
        "200":
          description: MFA setup data
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      secret:
                        type: string
                        description: Base32 TOTP secret
                      qr_url:
                        type: string
                        description: "otpauth:// provisioning URL for QR-code rendering"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: MFA is already enabled (MFA_ALREADY_ENABLED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /mfa/status:
    get:
      x-stability: internal
      summary: Get MFA status for the current user
      operationId: mfaStatus
      tags: [MFA]
      responses:
        "200":
          description: MFA status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      totp_enabled: {type: boolean}
        "401":
          $ref: "#/components/responses/Unauthorized"
  /mfa/verify-setup:
    post:
      x-stability: internal
      summary: Verify a TOTP code to complete MFA setup
      description: |
        Validates a code against the pending secret from POST /mfa/setup; on
        success enables MFA and returns 8 single-use backup codes (shown only
        once — only bcrypt hashes are stored). Session-token only.
      operationId: mfaVerifySetup
      tags: [MFA]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: {type: string, description: 6-digit TOTP code}
      responses:
        "200":
          description: MFA enabled; backup codes returned (once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      backup_codes:
                        type: array
                        items: {type: string}
        "400":
          description: Missing/invalid code (INVALID_CODE) or setup not initiated (MFA_NOT_SETUP)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: MFA is already enabled (MFA_ALREADY_ENABLED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /network-devices:
    get:
      x-stability: stable
      x-required-scope: network-devices:read
      summary: List network devices
      description: |
        Paginated, filterable list of SNMP network devices. Uses standard
        `page`/`per_page` paging and returns `{items, pagination}`. `search`
        matches hostname or IP address (case-insensitive substring). Invalid
        UUID filters are silently ignored rather than rejected.
      operationId: listNetworkDevices
      tags: [Network Devices]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: site_id
          in: query
          schema: {type: string, format: uuid}
        - name: client_id
          in: query
          schema: {type: string, format: uuid}
        - name: device_type
          in: query
          schema:
            type: string
            enum: [router, switch, printer, ap, ups, firewall, camera, phone, storage, other]
        - name: monitoring_mode
          in: query
          schema:
            type: string
            enum: [proxy, direct]
        - name: search
          in: query
          description: Substring match on hostname or IP address
          schema: {type: string}
      responses:
        "200":
          description: Network device list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          description: Lightweight list row (full detail via GET by id)
                          properties:
                            id: {type: string, format: uuid}
                            ip_address: {type: string}
                            hostname: {type: string, nullable: true}
                            device_type:
                              type: string
                              enum: [router, switch, printer, ap, ups, firewall, camera, phone, storage, other]
                            manufacturer: {type: string, nullable: true}
                            model: {type: string, nullable: true}
                            monitoring_mode:
                              type: string
                              enum: [proxy, direct]
                            is_online: {type: boolean}
                            last_seen: {type: string, format: date-time, nullable: true}
                            site_name: {type: string}
                            client_name: {type: string}
                            site_id: {type: string, format: uuid}
                            client_id: {type: string, format: uuid}
                            discovered_by_hostname:
                              type: string
                              nullable: true
                              description: Hostname of the agent that discovered the device
                            created_at: {type: string, format: date-time}
                      pagination:
                        $ref: '#/components/schemas/PaginationMeta'
              example:
                data:
                  items:
                    - id: "7e1a4f2b-9c3d-4e5f-8a6b-0c1d2e3f4a5b"
                      ip_address: "192.168.10.2"
                      hostname: "sw-core-01"
                      device_type: switch
                      manufacturer: "Hewlett Packard Enterprise"
                      model: "Aruba 2930F"
                      monitoring_mode: proxy
                      is_online: true
                      last_seen: "2026-06-11T09:10:00Z"
                      site_name: "Head Office"
                      client_name: "Greendale Surveyors Ltd"
                      site_id: "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
                      client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
                      discovered_by_hostname: "GREENDALE-FS01"
                      created_at: "2026-05-02T14:31:08Z"
                  pagination:
                    page: 1
                    per_page: 50
                    total: 6
                    total_pages: 1
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: network-devices:write
      summary: Create a network device
      description: |
        Manually registers a network device against a site. Defaults applied
        server-side: `device_type` "other", `snmp_version` "v2c",
        `snmp_port` 161, `monitoring_mode` "proxy", `poll_interval` 300
        seconds. One device per IP per site (duplicate inserts fail).
        `monitoring_mode` "proxy" polls via the LAN agent referenced by
        `discovered_by`; "direct" polls from the OpsMerge server, so the
        device must be reachable from it.
      operationId: createNetworkDevice
      tags: [Network Devices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [site_id, ip_address]
              properties:
                site_id: {type: string, format: uuid}
                discovered_by:
                  type: string
                  format: uuid
                  nullable: true
                  description: Agent used for proxy polling
                ip_address: {type: string}
                hostname: {type: string, nullable: true}
                device_type:
                  type: string
                  enum: [router, switch, printer, ap, ups, firewall, camera, phone, storage, other]
                  default: other
                manufacturer: {type: string, nullable: true}
                model: {type: string, nullable: true}
                serial_number: {type: string, nullable: true}
                snmp_version:
                  type: string
                  enum: [v1, v2c, v3]
                  default: v2c
                snmp_port: {type: integer, default: 161}
                snmp_community:
                  type: string
                  nullable: true
                  description: Treated as "public" at poll time when null
                snmp_v3_config:
                  type: object
                  nullable: true
                  description: SNMPv3 credential blob (free-form JSON)
                monitoring_mode:
                  type: string
                  enum: [proxy, direct]
                  default: proxy
                poll_interval:
                  type: integer
                  default: 300
                  description: Seconds between scheduled polls
            example:
              site_id: "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
              discovered_by: "b4dca6c1-2e7f-4b8a-9d0c-5f6a7b8c9d0e"
              ip_address: "192.168.10.5"
              hostname: "prn-recep-01"
              device_type: printer
              manufacturer: "Kyocera"
              model: "ECOSYS M5526cdw"
              snmp_version: v2c
              snmp_community: public
              monitoring_mode: proxy
      responses:
        "201":
          description: Device created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &NetworkDevice
                    type: object
                    description: Full network device record
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      site_id: {type: string, format: uuid}
                      site_name:
                        type: string
                        description: Hydrated on GET detail; empty on create/update responses
                      client_name:
                        type: string
                        description: Hydrated on GET detail; empty on create/update responses
                      discovered_by: {type: string, format: uuid, nullable: true}
                      discovered_by_hostname: {type: string, nullable: true}
                      ip_address: {type: string}
                      hostname: {type: string, nullable: true}
                      device_type:
                        type: string
                        enum: [router, switch, printer, ap, ups, firewall, camera, phone, storage, other]
                      manufacturer: {type: string, nullable: true}
                      model: {type: string, nullable: true}
                      serial_number: {type: string, nullable: true}
                      snmp_version:
                        type: string
                        enum: [v1, v2c, v3]
                      snmp_port: {type: integer}
                      snmp_community: {type: string, nullable: true}
                      snmp_v3_config:
                        type: object
                        nullable: true
                        description: SNMPv3 credential blob (free-form JSON)
                      monitoring_mode:
                        type: string
                        enum: [proxy, direct]
                      poll_interval: {type: integer}
                      is_online: {type: boolean}
                      last_seen: {type: string, format: date-time, nullable: true}
                      sys_descr:
                        type: string
                        nullable: true
                        description: MIB-2 sysDescr from the last poll
                      sys_name: {type: string, nullable: true}
                      sys_object_id: {type: string, nullable: true}
                      sys_uptime:
                        type: integer
                        format: int64
                        nullable: true
                        description: MIB-2 sysUpTime in hundredths of a second
                      sys_contact: {type: string, nullable: true}
                      sys_location: {type: string, nullable: true}
                      extra_data:
                        type: object
                        nullable: true
                        description: Free-form discovery extras (JSON)
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
              example:
                data: &NetworkDeviceExample
                  id: "7e1a4f2b-9c3d-4e5f-8a6b-0c1d2e3f4a5b"
                  org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                  site_id: "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
                  site_name: "Head Office"
                  client_name: "Greendale Surveyors Ltd"
                  discovered_by: "b4dca6c1-2e7f-4b8a-9d0c-5f6a7b8c9d0e"
                  discovered_by_hostname: "GREENDALE-FS01"
                  ip_address: "192.168.10.2"
                  hostname: "sw-core-01"
                  device_type: switch
                  manufacturer: "Hewlett Packard Enterprise"
                  model: "Aruba 2930F"
                  serial_number: "CN12345678"
                  snmp_version: v2c
                  snmp_port: 161
                  snmp_community: public
                  snmp_v3_config: null
                  monitoring_mode: proxy
                  poll_interval: 300
                  is_online: true
                  last_seen: "2026-06-11T09:10:00Z"
                  sys_descr: "Aruba JL258A 2930F-8G-PoE+-2SFP+ Switch"
                  sys_name: "sw-core-01"
                  sys_object_id: "1.3.6.1.4.1.11.2.3.7.11.181"
                  sys_uptime: 482910044
                  sys_contact: "it@greendalesurveyors.example"
                  sys_location: "Comms cabinet, 1st floor"
                  extra_data: null
                  created_at: "2026-05-02T14:31:08Z"
                  updated_at: "2026-06-11T09:10:00Z"
        "400":
          description: Invalid body (INVALID_BODY) or missing site_id / ip_address (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /network-devices/discover:
    post:
      x-stability: stable
      x-required-scope: network-devices:write
      summary: Run SNMP discovery via an agent
      description: |
        Sends an SNMP subnet-discovery command to the named agent over NATS
        and waits synchronously for the result (agent budget 120 s; the
        request waits up to 125 s — set client timeouts accordingly).
        Discovered devices are reported in the command output; `agent_id` is
        the agent's database UUID, not its NATS identifier.
      operationId: discoverNetworkDevices
      tags: [Network Devices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_id, subnet]
              properties:
                agent_id:
                  type: string
                  format: uuid
                  description: Database id of the agent that will scan the subnet
                subnet:
                  type: string
                  description: CIDR subnet to scan
                  example: "192.168.10.0/24"
                community:
                  type: string
                  default: public
                snmp_version:
                  type: string
                  enum: [v1, v2c, v3]
                  default: v2c
            example:
              agent_id: "b4dca6c1-2e7f-4b8a-9d0c-5f6a7b8c9d0e"
              subnet: "192.168.10.0/24"
              community: public
              snmp_version: v2c
      responses:
        "200":
          description: Discovery command output from the agent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      stdout: {type: string}
                      stderr: {type: string}
                      retcode: {type: integer}
                      execution_time:
                        type: number
                        description: Seconds the agent spent scanning
              example:
                data:
                  stdout: "discovered 4 devices on 192.168.10.0/24"
                  stderr: ""
                  retcode: 0
                  execution_time: 18.42
        "400":
          description: Invalid body (INVALID_BODY) or missing agent_id / subnet (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "504":
          description: Agent did not respond within 125 s (AGENT_TIMEOUT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /network-devices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: network-devices:read
      summary: Get a network device
      operationId: getNetworkDevice
      tags: [Network Devices]
      responses:
        "200":
          description: Full device detail (including SNMP config and MIB-2 system fields)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *NetworkDevice
              example:
                data: *NetworkDeviceExample
        "400":
          description: Invalid device ID (INVALID_ID)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: stable
      x-required-scope: network-devices:write
      summary: Update a network device
      description: |
        Partial update despite the PUT verb: only fields present in the body
        are applied; omitted fields are left untouched. There is no way to
        null a field via this endpoint.
      operationId: updateNetworkDevice
      tags: [Network Devices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: All fields optional; only provided fields are changed
              properties:
                site_id: {type: string, format: uuid}
                discovered_by: {type: string, format: uuid}
                ip_address: {type: string}
                hostname: {type: string}
                device_type:
                  type: string
                  enum: [router, switch, printer, ap, ups, firewall, camera, phone, storage, other]
                manufacturer: {type: string}
                model: {type: string}
                serial_number: {type: string}
                snmp_version:
                  type: string
                  enum: [v1, v2c, v3]
                snmp_port: {type: integer}
                snmp_community: {type: string}
                snmp_v3_config:
                  type: object
                  description: SNMPv3 credential blob (free-form JSON)
                monitoring_mode:
                  type: string
                  enum: [proxy, direct]
                poll_interval: {type: integer}
            example:
              hostname: "sw-core-01"
              monitoring_mode: proxy
              poll_interval: 600
      responses:
        "200":
          description: Updated device
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *NetworkDevice
        "400":
          description: Invalid device ID (INVALID_ID) or body (INVALID_BODY)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: network-devices:write
      summary: Delete a network device
      operationId: deleteNetworkDevice
      tags: [Network Devices]
      responses:
        "204":
          description: Device deleted
        "400":
          description: Invalid device ID (INVALID_ID)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /network-devices/{id}/poll:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: network-devices:write
      summary: Poll a device on demand
      description: |
        Performs an immediate SNMP poll. The response shape depends on the
        device's monitoring_mode:

        * `proxy` — the poll is relayed over NATS to the agent that
          discovered the device and the raw command output is returned
          (`{mode, stdout, stderr}`). Fails with 400 NO_AGENT when the
          device has no associated agent, 503 NATS_UNAVAILABLE, or 504
          AGENT_TIMEOUT after ~35 s.
        * `direct` — the OpsMerge server polls the device itself and returns
          parsed MIB-2 system fields. An unreachable device is still a 200
          with `is_reachable: false` and an `error` string — check the body,
          not just the status code.
      operationId: pollNetworkDevice
      tags: [Network Devices]
      responses:
        "200":
          description: Poll result (shape varies by monitoring mode)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - type: object
                        title: ProxyPollResult
                        properties:
                          mode:
                            type: string
                            enum: [proxy]
                          stdout: {type: string}
                          stderr: {type: string}
                      - type: object
                        title: DirectPollResult
                        properties:
                          mode:
                            type: string
                            enum: [direct]
                          is_reachable: {type: boolean}
                          error:
                            type: string
                            description: Present only when is_reachable is false
                          sys_descr: {type: string}
                          sys_name: {type: string}
                          sys_uptime:
                            type: integer
                            format: int64
                            description: MIB-2 sysUpTime in hundredths of a second
                          sys_contact: {type: string}
                          sys_location: {type: string}
              example:
                data:
                  mode: direct
                  is_reachable: true
                  sys_descr: "Aruba JL258A 2930F-8G-PoE+-2SFP+ Switch"
                  sys_name: "sw-core-01"
                  sys_uptime: 482910044
                  sys_contact: "it@greendalesurveyors.example"
                  sys_location: "Comms cabinet, 1st floor"
        "400":
          description: Invalid device ID (INVALID_ID) or proxy device with no associated agent (NO_AGENT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS not available for proxy polling (NATS_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "504":
          description: Agent did not respond within ~35 s (AGENT_TIMEOUT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /organization/managed-updates:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get org-wide managed-updates state and compliance summary
      description: |
        Returns the organisation default plus a fleet compliance summary
        (agents whose reported state matches the org default, those still
        pending, and mismatches).
      operationId: getOrgManagedUpdates
      tags: [Patches]
      responses:
        "200":
          description: Org default + compliance counters
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      managed: {type: boolean}
                      agents: {type: integer}
                      applied: {type: integer}
                      pending: {type: integer}
                      mismatch: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Set the org-wide managed-updates default
      description: |
        Sets `organizations.managed_updates` and fans out
        set-update-control to every agent in the org. Unlike
        client/site/agent scopes, `managed` is required and non-nullable
        here (the org is the root of the hierarchy).
      operationId: updateOrgManagedUpdates
      tags: [Patches]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [managed]
              properties:
                managed: {type: boolean}
      responses: *ManagedUpdatesFanOutResponses
  /organization/patch-auto-install:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get the org's auto-install safety valves
      description: Fleet-wide pause flag and per-org install concurrency cap for policy-driven auto-install.
      operationId: getOrgPatchAutoInstall
      tags: [Patches]
      responses:
        "200":
          description: Current safety-valve state
          content:
            application/json:
              schema: &OrgAutoInstallResponse
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      paused: {type: boolean}
                      concurrency: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Update the org's auto-install safety valves
      description: Partial update - omitted fields keep their current values. Returns the resulting state.
      operationId: updateOrgPatchAutoInstall
      tags: [Patches]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                paused: {type: boolean, nullable: true}
                concurrency:
                  type: integer
                  nullable: true
                  minimum: 0
                  maximum: 100
      responses:
        "200":
          description: Updated safety-valve state
          content:
            application/json:
              schema: *OrgAutoInstallResponse
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
  /organization/patch-policy:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get the org-wide default patch policy
      operationId: getOrgPatchPolicy
      tags: [Patches]
      responses:
        "200":
          description: Direct + effective patch-policy assignment
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PatchPolicyAssignmentView
        "401": {$ref: '#/components/responses/Unauthorized'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Assign or clear the org-wide default patch policy
      description: The target org is the caller's own tenant; there is no path parameter.
      operationId: assignOrgPatchPolicy
      tags: [Patches]
      requestBody:
        required: true
        content:
          application/json:
            schema: *PatchPolicyAssignmentRequest
      responses:
        "204":
          description: Assignment updated
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /organization/policy:
    get:
      x-stability: stable
      x-required-scope: policies:write
      summary: Get the organisation default policy assignments
      description: |
        Returns the org-level default workstation/server policies.
        Requires the policies manage permission (policies:write),
        including for this read.
      operationId: getOrgPolicy
      tags: [Policies]
      responses:
        "200":
          description: Organisation default policies
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      workstation_policy_id: {type: string, format: uuid, nullable: true}
                      server_policy_id: {type: string, format: uuid, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: stable
      x-required-scope: policies:write
      summary: Assign the organisation default policies
      description: |
        Sets the org-level default workstation/server policies (the
        bottom of the inheritance chain) and re-materialises policies for
        every agent in the org asynchronously.
      operationId: assignOrgPolicy
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema: *HierarchyPolicyAssignment
      responses:
        "200":
          description: Organisation default policy updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /organizations:
    get:
      x-stability: internal
      summary: List all organizations (platform admin)
      description: |
        Cross-tenant list, newest first. Platform-admin only
        (`users.is_platform_admin`); tenant superusers are refused.
      operationId: listOrganizations
      tags: [Organizations]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated organization list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &Organization
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            name: {type: string}
                            slug:
                              type: string
                              description: URL-safe tenant identifier; immutable after creation
                            is_active: {type: boolean}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      x-stability: internal
      summary: Create an organization (platform admin)
      description: |
        Creates a tenant and seeds its defaults (compliance templates, billing
        categories, PSA notification rules, sample alert workflows, anomaly
        settings) in one transaction. Slug must be globally unique.
      operationId: createOrganization
      tags: [Organizations]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, slug]
              properties:
                name: {type: string}
                slug: {type: string}
      responses:
        "201":
          description: Organization created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Organization
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Organization slug already exists (DUPLICATE_SLUG)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /organizations/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: internal
      summary: Get an organization by ID (platform admin)
      operationId: getOrganization
      tags: [Organizations]
      responses:
        "200":
          description: Organization details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Organization
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      x-stability: internal
      summary: Update an organization (platform admin)
      description: |
        Partial update — only `name` and `is_active` are mutable; the slug is
        fixed at creation. `is_active=false` suspends console access for the
        tenant.
      operationId: updateOrganization
      tags: [Organizations]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                is_active: {type: boolean}
      responses:
        "200":
          description: Updated organization
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Organization
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /organizations/{id}/mdm-enabled:
    parameters:
      - name: id
        in: path
        required: true
        description: Organization ID
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      summary: Get an organization's MDM beta flag
      description: |
        Reports whether the Android MDM beta is enabled for the organisation.
        Platform-admin only (the Brindleford-side flag, not tenant
        superuser) — there is no API-key scope for this endpoint. This flag
        gates every /mdm/* route via RequireMDMEnabled.
      operationId: getOrganizationMdmEnabled
      tags: [MDM]
      responses:
        "200":
          description: Current flag value
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      mdm_enabled:
                        type: boolean
              example:
                data:
                  mdm_enabled: true
        "400":
          description: Invalid organization ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
    put:
      x-stability: beta
      summary: Set an organization's MDM beta flag
      description: |
        Flips the per-org MDM beta flag — the single switch that admits a
        tenant to the Android MDM beta (all /mdm/* routes 404 while it is
        off). Platform-admin only; no API-key scope.
      operationId: setOrganizationMdmEnabled
      tags: [MDM]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled:
                  type: boolean
            example:
              enabled: true
      responses:
        "200":
          description: Flag updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      mdm_enabled:
                        type: boolean
        "400":
          description: Invalid organization ID or request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
  /password/reset:
    post:
      x-stability: internal
      summary: Complete a password reset
      description: |
        Consumes a reset token from the emailed link and sets a new password
        (minimum 12 characters — stricter than the authenticated change-
        password flow). On success the token and all sibling tokens are
        burned and every session for the user is revoked (forced re-login
        everywhere). Rate-limited like /login.
      operationId: passwordReset
      security: []
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, new_password]
              properties:
                token: {type: string, description: Raw token from the reset email link}
                new_password: {type: string, minLength: 12}
      responses:
        "204":
          description: Password updated; all sessions revoked
        "400":
          description: Password under 12 characters (WEAK_PASSWORD) or malformed body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Unknown, expired, or already-used token (INVALID_TOKEN)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
  /password/reset-request:
    post:
      x-stability: internal
      summary: Request a password-reset email
      description: |
        Accepts a username or email. ALWAYS returns 204 whether or not a
        matching account exists (no enumeration); when one does, a single-use
        reset token (1-hour TTL) is emailed. Rate-limited like /login.
      operationId: passwordResetRequest
      security: []
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identifier]
              properties:
                identifier: {type: string, description: Username or email address}
      responses:
        "204":
          description: Accepted (returned regardless of account existence)
        "400":
          description: Missing identifier (MISSING_IDENTIFIER) or malformed JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
  /patch-dispatch-log:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: List recent patch-dispatcher decisions
      description: |
        Operator forensics view of the automatic patch dispatcher, newest
        first. Uses `limit` (default and UI cap 100, hard max 500) with no
        offset paging.
      operationId: listPatchDispatchLog
      tags: [Patches]
      parameters:
        - name: agent_id
          in: query
          description: Filter to a single agent
          schema: {type: string, format: uuid}
        - name: limit
          in: query
          schema: {type: integer, default: 100, maximum: 500}
      responses:
        "200":
          description: Dispatch log entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        agent_id: {type: string, format: uuid}
                        agent_name: {type: string, nullable: true}
                        policy_id: {type: string, format: uuid, nullable: true}
                        policy_name: {type: string, nullable: true}
                        window_name: {type: string}
                        fired_at: {type: string, format: date-time}
                        identifier_count: {type: integer}
                        river_job_id: {type: integer, format: int64, nullable: true}
                        queued_offline: {type: boolean}
                        outcome: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
  /patch-policies:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: List patch policies
      description: Returns every patch policy in the organisation (no pagination).
      operationId: listPatchPolicies
      tags: [Patches]
      responses:
        "200":
          description: Patch policy list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &PatchPolicy
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        name: {type: string}
                        auto_approve_critical:
                          type: boolean
                          description: Legacy flag; superseded by auto_approve_severities
                        auto_approve_security:
                          type: boolean
                          description: Legacy flag; superseded by auto_approve_classifications
                        auto_approve_other:
                          type: boolean
                          description: Legacy flag
                        auto_approve_severities:
                          type: array
                          items: {type: string}
                        auto_approve_classifications:
                          type: array
                          items: {type: string}
                        deferral_days: {type: integer}
                        auto_install: {type: boolean}
                        install_schedule:
                          type: array
                          items: &InstallWindow
                            type: object
                            properties:
                              name: {type: string}
                              severities:
                                type: array
                                items: {type: string}
                                description: Severities this window handles; empty = catch-all
                              recurrence:
                                type: string
                                enum: [daily, weekly, monthly]
                              time:
                                type: string
                                description: HH:MM, 24-hour
                              days:
                                type: array
                                items: {type: integer, minimum: 0, maximum: 6}
                                description: Weekly only; 0=Sunday
                              day_of_month:
                                type: integer
                                nullable: true
                                minimum: 1
                                maximum: 28
                              patch_tuesday:
                                type: boolean
                                description: Monthly only; second Tuesday
                              max_duration_hours:
                                type: integer
                                minimum: 0
                                maximum: 24
                                description: 0 = unlimited
                        scan_interval_hours: {type: integer}
                        scan_time: {type: string, nullable: true}
                        excluded_kbs:
                          type: array
                          items: {type: string}
                        exclusions:
                          type: array
                          items: &PatchExclusion
                            type: object
                            properties:
                              type:
                                type: string
                                enum: [kb, category, title_glob, classification, severity]
                              value: {type: string}
                              reason: {type: string}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
    post:
      x-stability: internal
      x-required-scope: patches:write
      summary: Create a patch policy
      operationId: createPatchPolicy
      tags: [Patches]
      requestBody:
        required: true
        content:
          application/json:
            schema: &PatchPolicyWriteRequest
              type: object
              properties:
                name: {type: string, maxLength: 255}
                auto_approve_critical: {type: boolean, nullable: true}
                auto_approve_security: {type: boolean, nullable: true}
                auto_approve_other: {type: boolean, nullable: true}
                auto_approve_severities:
                  type: array
                  items: {type: string}
                  example: ["Critical", "Important"]
                auto_approve_classifications:
                  type: array
                  items: {type: string}
                  example: ["Security Updates"]
                deferral_days: {type: integer, nullable: true}
                auto_install: {type: boolean, nullable: true}
                install_schedule:
                  type: array
                  items: *InstallWindow
                scan_interval_hours:
                  type: integer
                  nullable: true
                  minimum: 1
                  maximum: 720
                scan_time: {type: string, nullable: true}
                excluded_kbs:
                  type: array
                  items: {type: string}
                exclusions:
                  type: array
                  items: *PatchExclusion
      responses:
        "201":
          description: Policy created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PatchPolicy
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
  /patch-policies/{id}:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get a patch policy
      operationId: getPatchPolicy
      tags: [Patches]
      parameters: &PatchPolicyIDParam
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Patch policy
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PatchPolicy
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Update a patch policy
      description: Partial update - omitted fields keep their current values. `name` is optional here (unlike create).
      operationId: updatePatchPolicy
      tags: [Patches]
      parameters: *PatchPolicyIDParam
      requestBody:
        required: true
        content:
          application/json:
            schema: *PatchPolicyWriteRequest
      responses:
        "200":
          description: Updated patch policy
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PatchPolicy
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    delete:
      x-stability: internal
      x-required-scope: patches:write
      summary: Delete a patch policy
      operationId: deletePatchPolicy
      tags: [Patches]
      parameters: *PatchPolicyIDParam
      responses:
        "204":
          description: Policy deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /pax8/webhook/{token}:
    post:
      x-stability: internal
      operationId: receivePax8Webhook
      summary: Pax8 webhook
      description: |
        Receives Pax8 event deliveries. Pax8 does NOT sign payloads, so
        defence-in-depth uses two independent per-tenant secrets that must BOTH
        match: the `{token}` path segment (32-byte hex `webhook_path_token`,
        unique-indexed on pax8_connections) and the raw `Authorization` header
        value, whose SHA-256 is constant-time compared against the stored
        `webhook_secret_sha256`. A path-token match with a header mismatch (or
        vice versa) returns a 401 identical in body and timing to a fully
        unknown token; rejected deliveries are still persisted with a NULL org
        for forensics. Accepted deliveries are persisted to pax8_webhook_events
        and a River job is enqueued; Pax8 retries non-2xx at 10s/30s/60s.
      tags: ["Webhooks (Inbound)"]
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: Per-tenant webhook path token (32 bytes hex) issued at Pax8 connection time.
          schema:
            type: string
        - name: Authorization
          in: header
          required: true
          description: Per-tenant webhook secret echoed verbatim by Pax8 (NOT a Bearer/Basic scheme); SHA-256 of the header value is compared against the stored hash.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                events:
                  type: array
                  items:
                    type: object
                    properties:
                      topic:
                        type: string
                      eventId:
                        type: string
                      eventTimestamp:
                        type: string
                      resource:
                        type: object
                        description: Topic-specific entity reference (subscription/company/product ids under varying keys).
      responses:
        "200":
          description: Delivery persisted; async dispatch enqueued (enqueue failures fall back to the periodic sync).
        "400":
          description: Authenticated but unparseable payload (so Pax8 stops retrying).
        "401":
          description: Path token or Authorization secret mismatch (plain text, indistinguishable cases).
        "500":
          description: Audit-row persist failed — Pax8 will retry.
  /permissions:
    get:
      x-stability: stable
      x-required-scope: roles:read
      summary: List all available permission keys
      description: |
        Flat list of every permission key the platform knows (e.g.
        `agents.list`, `psa.tickets.edit`). These are the valid keys for a
        role's `permissions` map.
      operationId: listPermissions
      tags: [Roles]
      responses:
        "200":
          description: Permission key list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /platform/authenticode:
    get:
      x-stability: internal
      summary: Get platform Authenticode certificate status
      description: |
        Read-only status of the platform-global Authenticode cert chain
        (subject, issuer, validity window, SHA-1 thumbprint). There is no
        upload endpoint — the chain is provisioned on-host or by CI, and
        the private key never leaves the HSM. `configured=false` includes
        a `reason`. Platform admin only.
      operationId: getPlatformAuthenticodeStatus
      tags: [CodeSigning]
      responses:
        "200":
          description: Certificate status (never includes key material)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      configured:
                        type: boolean
                      reason:
                        type: string
                        description: Present when configured=false
                      subject:
                        type: string
                      issuer:
                        type: string
                      not_before:
                        type: string
                        format: date-time
                      not_after:
                        type: string
                        format: date-time
                      sha1:
                        type: string
                        description: SHA-1 thumbprint (hex)
        "403":
          $ref: "#/components/responses/Forbidden"
  /policies:
    get:
      x-stability: stable
      x-required-scope: policies:read
      summary: List policies
      description: |
        Returns every policy with check/task template counts, ordered by
        name. The response uses the paginated envelope but is always a
        single page containing every row (no real paging).
      operationId: listPolicies
      tags: [Policies]
      responses:
        "200":
          description: Policy list (single-page paginated envelope)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &Policy
                          type: object
                          description: One row from the policies table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string, maxLength: 255}
                            description: {type: string, nullable: true}
                            active: {type: boolean}
                            enforced:
                              type: boolean
                              description: When true, materialised checks/tasks cannot be locally overridden
                            check_count: {type: integer}
                            task_count: {type: integer}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination:
                        type: object
                        properties:
                          page: {type: integer}
                          per_page: {type: integer}
                          total: {type: integer}
                          total_pages: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: policies:write
      summary: Create a policy
      operationId: createPolicy
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string, maxLength: 255}
                description: {type: string}
                active: {type: boolean, default: true}
                enforced: {type: boolean, default: false}
      responses:
        "201":
          description: Policy created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Policy
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /policies/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: policies:read
      summary: Get policy details
      description: Returns the policy with its full check and task template lists.
      operationId: getPolicy
      tags: [Policies]
      responses:
        "200":
          description: Policy detail with templates
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - *Policy
                      - type: object
                        properties:
                          checks:
                            type: array
                            items: &PolicyCheck
                              type: object
                              description: One row from the policy_checks table
                              properties:
                                id: {type: string, format: uuid}
                                org_id: {type: string, format: uuid}
                                policy_id: {type: string, format: uuid}
                                check_type:
                                  type: string
                                  enum: [cpu, memory, disk, service, ping, http, script, eventlog]
                                name: {type: string, maxLength: 255}
                                config:
                                  type: object
                                  description: Type-specific configuration (same keys as agent checks)
                                warn_threshold: {type: number, nullable: true}
                                err_threshold: {type: number, nullable: true}
                                check_interval: {type: integer, minimum: 30}
                                alert_severity:
                                  type: string
                                  enum: [info, warning, error]
                                enabled: {type: boolean}
                                created_at: {type: string, format: date-time}
                          tasks:
                            type: array
                            items: &PolicyTask
                              type: object
                              description: One row from the policy_tasks table
                              properties:
                                id: {type: string, format: uuid}
                                org_id: {type: string, format: uuid}
                                policy_id: {type: string, format: uuid}
                                name: {type: string, maxLength: 255}
                                task_type:
                                  type: string
                                  enum: [manual, daily, weekly, monthly, once, on_boot, on_onboarding, on_check_fail]
                                actions:
                                  type: array
                                  items: {type: object}
                                run_time: {type: string, nullable: true}
                                weekly_days:
                                  type: array
                                  nullable: true
                                  items: {type: integer, minimum: 0, maximum: 6}
                                monthly_day: {type: integer, nullable: true}
                                enabled: {type: boolean}
                                continue_on_error: {type: boolean}
                                trigger_check_name:
                                  type: string
                                  nullable: true
                                  description: For on_check_fail templates — resolved to a concrete check at materialisation
                                created_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: stable
      x-required-scope: policies:write
      summary: Update a policy
      description: Partial update — only fields present in the body are changed.
      operationId: updatePolicy
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
                description: {type: string}
                active: {type: boolean}
                enforced: {type: boolean}
      responses:
        "200":
          description: Policy updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Policy
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: policies:write
      summary: Delete a policy
      operationId: deletePolicy
      tags: [Policies]
      responses:
        "204":
          description: Policy deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /policies/{id}/checks:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: policies:read
      summary: List check templates in a policy
      operationId: listPolicyChecks
      tags: [Policies]
      responses:
        "200":
          description: Policy check templates
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: *PolicyCheck
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: policies:write
      summary: Add a check template to a policy
      description: |
        Adds a check template and enqueues re-materialisation for all
        agents covered by the policy.
      operationId: createPolicyCheck
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [check_type, name]
              properties:
                check_type:
                  type: string
                  enum: [cpu, memory, disk, service, ping, http, script, eventlog]
                name: {type: string, maxLength: 255}
                config: {type: object}
                warn_threshold: {type: number, nullable: true}
                err_threshold: {type: number, nullable: true}
                check_interval: {type: integer, minimum: 30, default: 300}
                alert_severity:
                  type: string
                  enum: [info, warning, error]
                  default: error
                enabled: {type: boolean, default: true}
      responses:
        "201":
          description: Check template added
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PolicyCheck
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /policies/{id}/scripts:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: policies:read
      summary: List scheduled-script bindings on a policy
      description: |
        Cron-scheduled script bindings, joined with the script name,
        ordered by script name. A River dispatcher fans due bindings out
        to all agents covered by the policy.
      operationId: listPolicyScripts
      tags: [Policies]
      responses:
        "200":
          description: Scheduled-script bindings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &PolicyScript
                      type: object
                      description: One row from the policy_scripts table
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        policy_id: {type: string, format: uuid}
                        script_id: {type: string, format: uuid}
                        script_name: {type: string}
                        cron_expr:
                          type: string
                          description: Standard 5-field cron expression
                        catchup_mode:
                          type: string
                          enum: [coalesce, queue_all]
                          description: How missed runs are handled for offline agents
                        enabled: {type: boolean}
                        last_run_at: {type: string, format: date-time, nullable: true}
                        next_run_at: {type: string, format: date-time}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: policies:write
      summary: Schedule a script on a policy
      description: |
        Creates a cron-scheduled script binding. The (policy, script) pair
        is unique — a duplicate returns 409 DUPLICATE. An unparseable
        cron_expr returns 400 with the parser's message.
      operationId: createPolicyScript
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [script_id, cron_expr]
              properties:
                script_id: {type: string, format: uuid}
                cron_expr:
                  type: string
                  description: Standard 5-field cron expression
                catchup_mode:
                  type: string
                  enum: [coalesce, queue_all]
                  default: coalesce
                enabled: {type: boolean, default: true}
      responses:
        "201":
          description: Binding created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PolicyScript
        "400":
          description: Validation failure or invalid cron expression
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Policy or script not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Script already scheduled on this policy (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /policies/{id}/scripts/{binding_id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: binding_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    put:
      x-stability: stable
      x-required-scope: policies:write
      summary: Update a scheduled-script binding
      description: |
        Partial update of cron_expr/catchup_mode/enabled. Changing
        cron_expr recomputes next_run_at.
      operationId: updatePolicyScript
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                cron_expr: {type: string}
                catchup_mode:
                  type: string
                  enum: [coalesce, queue_all]
                enabled: {type: boolean}
      responses:
        "200":
          description: Binding updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PolicyScript
        "400":
          description: Validation failure or invalid cron expression
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: policies:write
      summary: Remove a scheduled-script binding
      operationId: deletePolicyScript
      tags: [Policies]
      responses:
        "204":
          description: Binding removed
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /policies/{id}/scripts/{binding_id}/run-now:
    post:
      x-stability: stable
      x-required-scope: policies:write
      summary: Run a scheduled script immediately
      description: |
        Enqueues an immediate fan-out of the binding to all
        currently-applicable agents without waiting for the next cron
        tick. next_run_at is not modified. No request body.
      operationId: runPolicyScriptNow
      tags: [Policies]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: binding_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "202":
          description: Fan-out enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [enqueued]
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /policies/{id}/tasks:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: policies:read
      summary: List task templates in a policy
      operationId: listPolicyTasks
      tags: [Policies]
      responses:
        "200":
          description: Policy task templates
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: *PolicyTask
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: policies:write
      summary: Add a task template to a policy
      description: |
        Adds a task template and enqueues re-materialisation. Event-driven
        types (on_boot, on_onboarding, on_check_fail) are allowed here;
        on_check_fail requires trigger_check_name, which the materialiser
        resolves to a concrete check per agent.
      operationId: createPolicyTask
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, task_type, actions]
              properties:
                name: {type: string, maxLength: 255}
                task_type:
                  type: string
                  enum: [manual, daily, weekly, monthly, once, on_boot, on_onboarding, on_check_fail]
                actions:
                  type: array
                  minItems: 1
                  items: {type: object}
                run_time: {type: string, description: "HH:MM"}
                weekly_days:
                  type: array
                  items: {type: integer, minimum: 0, maximum: 6}
                monthly_day: {type: integer, minimum: 1, maximum: 31}
                enabled: {type: boolean, default: true}
                continue_on_error: {type: boolean, default: false}
                trigger_check_name:
                  type: string
                  description: Required for on_check_fail templates
      responses:
        "201":
          description: Task template added
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PolicyTask
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /policies/{policyID}/checks/{checkID}:
    parameters:
      - name: policyID
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: checkID
        in: path
        required: true
        schema:
          type: string
          format: uuid
    put:
      x-stability: stable
      x-required-scope: policies:write
      summary: Update a policy check template
      description: Partial update; triggers re-materialisation for covered agents.
      operationId: updatePolicyCheck
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
                config: {type: object}
                warn_threshold: {type: number, nullable: true}
                err_threshold: {type: number, nullable: true}
                check_interval: {type: integer, minimum: 30}
                alert_severity:
                  type: string
                  enum: [info, warning, error]
                enabled: {type: boolean}
      responses:
        "200":
          description: Check template updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PolicyCheck
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: policies:write
      summary: Remove a check template from a policy
      operationId: deletePolicyCheck
      tags: [Policies]
      responses:
        "204":
          description: Check template removed
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /policies/{policyID}/tasks/{taskID}:
    parameters:
      - name: policyID
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: taskID
        in: path
        required: true
        schema:
          type: string
          format: uuid
    put:
      x-stability: stable
      x-required-scope: policies:write
      summary: Update a policy task template
      description: |
        Partial update; triggers re-materialisation. trigger_check_name
        distinguishes absent (unchanged) from null (explicitly cleared).
      operationId: updatePolicyTask
      tags: [Policies]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
                task_type:
                  type: string
                  enum: [manual, daily, weekly, monthly, once, on_boot, on_onboarding, on_check_fail]
                actions:
                  type: array
                  minItems: 1
                  items: {type: object}
                run_time: {type: string, description: "HH:MM"}
                weekly_days:
                  type: array
                  items: {type: integer, minimum: 0, maximum: 6}
                monthly_day: {type: integer, minimum: 1, maximum: 31}
                enabled: {type: boolean}
                continue_on_error: {type: boolean}
                trigger_check_name: {type: string, nullable: true}
      responses:
        "200":
          description: Task template updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PolicyTask
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: policies:write
      summary: Remove a task template from a policy
      operationId: deletePolicyTask
      tags: [Policies]
      responses:
        "204":
          description: Task template removed
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/billing/invoices:
    get:
      x-stability: internal
      operationId: listPortalInvoices
      summary: List the contact's client invoices (portal)
      description: |
        Returns invoices for the authenticated portal contact's
        `client_id`, newest `issued_at` first. Only `issued`, `partial`
        and `paid` invoices are visible — never draft, void or
        written-off rows. Unpaginated. The response is a bare array in
        the `data` envelope (no `items`/`total` wrapper).

        Requires a `client_portal` user on a PSA or Bundle tier tenant;
        the billing section can additionally be disabled per-tenant via
        the portal branding `show_billing_section` gate (UI-level only —
        this endpoint does not check it). Contacts without a linked
        client receive 403.
      tags: [Client Portal]
      responses:
        "200":
          description: Invoice list (issued/partial/paid only)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &PortalInvoice
                      type: object
                      description: |
                        One row from the invoices table, serialised from
                        the same Go struct as the MSP surface. The portal
                        queries only load the core columns; optional
                        fields not loaded (dunning timestamps, HMRC
                        columns, FX fields) are omitted from the JSON,
                        and `reverse_charge` always serialises as false.
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        client_id: {type: string, format: uuid}
                        invoice_number: {type: string, example: "INV-2026-0042"}
                        status:
                          type: string
                          enum: [issued, partial, paid]
                          description: Portal-visible statuses only; other statuses are filtered server-side
                        subtotal_cents:
                          type: integer
                          format: int64
                          description: Net subtotal in minor currency units (pence)
                        tax_rate_bps:
                          type: integer
                          description: Tax rate in basis points (2000 = 20% VAT)
                        tax_cents: {type: integer, format: int64}
                        total_cents: {type: integer, format: int64}
                        currency_code: {type: string, example: GBP}
                        issued_at: {type: string, format: date-time, nullable: true}
                        due_date: {type: string, format: date-time, nullable: true}
                        paid_at: {type: string, format: date-time, nullable: true}
                        notes: {type: string}
                        reverse_charge:
                          type: boolean
                          description: Not loaded by the portal queries; always false here
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - &PortalInvoiceExample
                    id: "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                    invoice_number: "INV-2026-0042"
                    status: issued
                    subtotal_cents: 171000
                    tax_rate_bps: 2000
                    tax_cents: 34200
                    total_cents: 205200
                    currency_code: "GBP"
                    issued_at: "2026-06-01T00:05:00Z"
                    due_date: "2026-07-01T00:00:00Z"
                    paid_at: null
                    notes: ""
                    reverse_charge: false
                    created_at: "2026-06-01T00:05:00Z"
                    updated_at: "2026-06-01T00:05:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/billing/invoices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      operationId: getPortalInvoice
      summary: Get invoice detail (portal)
      description: |
        Returns the invoice with its line items, the active Stripe
        payment link (if any) and the payment transactions recorded
        against it. Invoices belonging to another client, or in a
        non-portal-visible status, return 404 — never 403.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      responses:
        "200":
          description: Invoice detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      invoice: *PortalInvoice
                      items:
                        type: array
                        items:
                          type: object
                          description: One row from the invoice_items table
                          properties:
                            id: {type: string, format: uuid}
                            invoice_id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            service_id: {type: string, format: uuid, nullable: true}
                            tax_code_id: {type: string, format: uuid, nullable: true}
                            description: {type: string}
                            quantity: {type: integer}
                            unit_price_cents: {type: integer, format: int64}
                            subtotal_cents: {type: integer, format: int64}
                            quantity_snapshot:
                              nullable: true
                              description: Audit JSON of the asset-count snapshot for contract-generated lines; omitted when absent
                            contract_line_id: {type: string, format: uuid, nullable: true}
                            supply_date: {type: string, format: date-time, nullable: true}
                            service_period_start: {type: string, format: date-time, nullable: true}
                            service_period_end: {type: string, format: date-time, nullable: true}
                            created_at: {type: string, format: date-time}
                      payment_link:
                        type: object
                        nullable: true
                        description: The invoice's Stripe payment link; null unless one exists with status=active
                        properties:
                          id: {type: string, format: uuid}
                          invoice_id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          stripe_payment_intent_id: {type: string}
                          stripe_checkout_session_id: {type: string}
                          stripe_payment_link_url: {type: string}
                          expires_at: {type: string, format: date-time, nullable: true}
                          status: {type: string}
                          created_at: {type: string, format: date-time}
                      payment_url:
                        type: string
                        description: Convenience copy of the active payment link URL; empty string when there is no active link
                      transactions:
                        type: array
                        items:
                          type: object
                          description: One row from the transactions table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            invoice_id: {type: string, format: uuid, nullable: true}
                            type: {type: string, example: payment}
                            amount_cents: {type: integer, format: int64}
                            stripe_payment_intent_id: {type: string, description: Omitted when empty}
                            recorded_at: {type: string, format: date-time}
                            notes: {type: string, description: Omitted when empty}
                            external_id: {type: string, description: Omitted when empty}
                            external_system: {type: string, description: Omitted when empty}
                            external_payment_method: {type: string, description: Omitted when empty}
                            external_synced_at: {type: string, format: date-time, nullable: true}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/billing/invoices/{id}/pay:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      operationId: getPortalInvoicePayURL
      summary: Get the pay-now URL for an invoice (portal)
      description: |
        Returns the active Stripe payment link URL for the invoice. 404
        when the invoice belongs to another client, does not exist, or
        has no active payment link.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      responses:
        "200":
          description: Active payment link URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url:
                        type: string
                        description: Stripe-hosted payment page URL
              example:
                data:
                  url: "https://buy.stripe.com/test_4gw5mF2Yq0Aa1"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/billing/payments:
    get:
      x-stability: internal
      operationId: listPortalPayments
      summary: List the contact's client payment history (portal)
      description: |
        Returns `type=payment` transactions for the contact's client,
        newest first, each enriched with the parent `invoice_number`.
        Only the portal-safe columns are exposed — internal notes and
        Stripe identifiers stay server-side. Unpaginated bare array.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      responses:
        "200":
          description: Payment list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        invoice_id: {type: string, format: uuid, nullable: true}
                        invoice_number: {type: string}
                        type: {type: string, example: payment}
                        amount_cents: {type: integer, format: int64}
                        recorded_at: {type: string, format: date-time}
              example:
                data:
                  - id: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
                    invoice_id: "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70"
                    invoice_number: "INV-2026-0042"
                    type: payment
                    amount_cents: 205200
                    recorded_at: "2026-06-08T16:20:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/branding:
    get:
      x-stability: internal
      operationId: getPortalBranding
      summary: Get portal branding (contact-facing)
      description: |
        Returns the tenant's portal branding for the PortalLayout boot
        sequence: colours, presigned logo URL, footer/legal links, and
        the per-tenant feature gates that show/hide portal sections.
        `logo_url` is a presigned 1-hour B2 download URL, or null when
        no logo is set or object storage is not configured.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      responses:
        "200":
          description: Branding configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      logo_url: {type: string, nullable: true}
                      primary_color:
                        type: string
                        nullable: true
                        description: Hex colour (e.g. "#0B6CF2"); null = platform default
                      secondary_color: {type: string, nullable: true}
                      footer_text: {type: string, nullable: true}
                      privacy_url: {type: string, nullable: true}
                      terms_url: {type: string, nullable: true}
                      feature_gates: &PortalFeatureGates
                        type: object
                        description: Per-tenant portal section toggles (client_portal_settings)
                        properties:
                          show_billing_section: {type: boolean}
                          show_kb_section: {type: boolean}
                          show_documents_section: {type: boolean}
                          allow_ticket_creation: {type: boolean}
              example:
                data:
                  logo_url: null
                  primary_color: "#0B6CF2"
                  secondary_color: null
                  footer_text: "Acme Managed Services — support@acmemanaged.example"
                  privacy_url: "https://www.acmemanaged.example/privacy"
                  terms_url: null
                  feature_gates:
                    show_billing_section: true
                    show_kb_section: true
                    show_documents_section: true
                    allow_ticket_creation: true
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/documents:
    get:
      x-stability: internal
      operationId: listPortalDocuments
      summary: List documents for a target (portal)
      description: |
        Returns documents associated to the given target. Both
        `target_type` and `target_id` are required (400 otherwise).
        Unpaginated.

        Requires a `client_portal` user on a PSA or Bundle tier tenant,
        and the tenant's `show_documents_section` portal gate must be on
        (403 FEATURE_DISABLED otherwise — every /portal/documents
        endpoint checks this gate).
      tags: [Client Portal]
      parameters:
        - name: target_type
          in: query
          required: true
          schema: {type: string, example: ticket}
        - name: target_id
          in: query
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Document list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      documents:
                        type: array
                        items:
                          type: object
                          description: One row from the documents table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            doc_type: {type: string, example: file}
                            title: {type: string, nullable: true}
                            content:
                              type: string
                              nullable: true
                              description: Rich-text body for article-type documents; omitted when empty
                            filename: {type: string, nullable: true}
                            mime_type: {type: string, nullable: true}
                            size_bytes: {type: integer, format: int64, nullable: true}
                            b2_bucket: {type: string, nullable: true}
                            b2_key: {type: string, nullable: true}
                            uploaded_by: {type: string, format: uuid}
                            uploader_kind: {type: string, enum: [staff, contact]}
                            deleted_at: {type: string, format: date-time, nullable: true}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/documents/confirm-upload:
    post:
      x-stability: internal
      operationId: confirmPortalDocumentUpload
      summary: Confirm a presigned document upload (portal)
      description: |
        Second leg of the two-step upload: after PUTting the bytes to
        the presigned URL, the portal confirms so the server can verify
        the object exists in B2 (HeadObject, including a stored-MIME
        re-check) and insert the documents row with
        `uploader_kind=contact`.

        Requires a `client_portal` user on a PSA or Bundle tier tenant
        plus the `show_documents_section` gate.
      tags: [Client Portal]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                document_id: {type: string, format: uuid}
                b2_key: {type: string}
                b2_bucket: {type: string}
                filename: {type: string}
                mime_type: {type: string}
                size_bytes: {type: integer, format: int64}
                target_type: {type: string, example: ticket}
                target_id: {type: string, format: uuid}
      responses:
        "201":
          description: Document recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      filename: {type: string}
                      mime_type: {type: string}
                      size_bytes: {type: integer, format: int64}
                      b2_bucket: {type: string}
                      b2_key: {type: string}
                      uploader_kind: {type: string, example: contact}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "415":
          description: mime_type (requested or stored) not on the server whitelist (MIME_NOT_ALLOWED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "422":
          description: Object not found in storage (OBJECT_NOT_FOUND) — the presigned PUT never happened
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Object storage not configured (STORAGE_UNAVAILABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /portal/documents/presigned-upload:
    post:
      x-stability: internal
      operationId: presignPortalDocumentUpload
      summary: Presign a document upload (portal)
      description: |
        First leg of the two-step upload: validates the request and
        returns a 15-minute presigned B2 PUT URL plus the generated
        document id and object key. Portal contacts may only attach
        documents to tickets (`target_type` must be `ticket`); files are
        capped at 100 MB and the MIME type must be on the server
        whitelist (images, PDF, text/CSV, Office documents, zip).

        Requires a `client_portal` user on a PSA or Bundle tier tenant
        plus the `show_documents_section` gate.
      tags: [Client Portal]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                filename: {type: string}
                mime_type: {type: string}
                size_bytes: {type: integer, format: int64}
                target_type:
                  type: string
                  description: Must be "ticket" on the portal surface
                target_id: {type: string, format: uuid}
      responses:
        "200":
          description: Presigned upload URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      upload_url: {type: string}
                      document_id: {type: string, format: uuid}
                      b2_key: {type: string}
                      b2_bucket: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "413":
          description: size_bytes exceeds the 100 MB cap (FILE_TOO_LARGE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "415":
          description: mime_type not on the server whitelist (MIME_NOT_ALLOWED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Object storage not configured (STORAGE_UNAVAILABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /portal/documents/quota:
    get:
      x-stability: internal
      operationId: getPortalDocumentsQuota
      summary: Get tenant document storage usage (portal)
      description: |
        Returns the tenant's document storage usage and quota. The
        portal surface always reports the PSA-tier quota (10 GB).

        Requires a `client_portal` user on a PSA or Bundle tier tenant
        plus the `show_documents_section` gate.
      tags: [Client Portal]
      responses:
        "200":
          description: Usage and quota in bytes
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      used_bytes: {type: integer, format: int64}
                      quota_bytes: {type: integer, format: int64, example: 10737418240}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/documents/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    delete:
      x-stability: internal
      operationId: deletePortalDocument
      summary: Delete an own-uploaded document (portal)
      description: |
        Soft-deletes a document. Portal contacts may only delete
        documents they uploaded themselves (403 otherwise). The stored
        object is moved to a deleted/ prefix in B2 best-effort.

        Requires a `client_portal` user on a PSA or Bundle tier tenant
        plus the `show_documents_section` gate.
      tags: [Client Portal]
      responses:
        "204":
          description: Document deleted
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/documents/{id}/download:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      operationId: downloadPortalDocument
      summary: Get a presigned download URL for a document (portal)
      description: |
        Returns a presigned 1-hour B2 download URL for the document (the
        response is JSON, not the file bytes).

        Requires a `client_portal` user on a PSA or Bundle tier tenant
        plus the `show_documents_section` gate.
      tags: [Client Portal]
      responses:
        "200":
          description: Presigned download URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      url: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "410":
          description: Document has been deleted (GONE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "422":
          description: Document row has no stored file (NO_FILE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Object storage not configured (STORAGE_UNAVAILABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /portal/kb/articles:
    get:
      x-stability: internal
      operationId: listPortalKbArticles
      summary: List or search knowledge base articles (portal)
      description: |
        Portal read-only KB list. Always filtered to
        `visibility=internal` — private (MSP-only) articles never reach
        the portal. Without `q`, returns article rows paginated by
        `limit`/`offset` with optional `category_id` filter. With `q`,
        performs full-text search and returns ranked results
        (id/title/visibility/excerpt/updated_at/rank) plus a `query` echo
        instead of full article rows.

        Requires a `client_portal` user on a PSA or Bundle tier tenant,
        and the tenant's `show_kb_section` portal gate must be on —
        when disabled the response is 403 with body
        `{"data": {"error": "feature_disabled"}}` (not the standard
        error envelope).
      tags: [Client Portal]
      parameters:
        - name: q
          in: query
          description: Full-text search query; switches the response to ranked search results
          schema: {type: string}
        - name: category_id
          in: query
          description: Filter to a single category (ignored in search mode)
          schema: {type: string, format: uuid}
        - name: limit
          in: query
          description: Maximum rows returned
          schema: {type: integer, default: 50}
        - name: offset
          in: query
          description: Rows to skip (list mode only)
          schema: {type: integer, default: 0}
      responses:
        "200":
          description: Article list (or search results when `q` is set)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        description: Article rows in list mode; ranked search results (id/title/visibility/excerpt/updated_at/rank) in search mode
                        items: &PortalKbArticle
                          type: object
                          description: One internal-visibility KB article
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            category_id: {type: string, format: uuid, nullable: true}
                            slug: {type: string, nullable: true}
                            title: {type: string}
                            summary: {type: string}
                            body:
                              type: string
                              description: Markdown article body
                            visibility:
                              type: string
                              enum: [internal]
                              description: Always internal on the portal surface
                            author_id: {type: string, format: uuid}
                            published_at: {type: string, format: date-time, nullable: true}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      query:
                        type: string
                        description: Echo of the search query (search mode only)
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/kb/articles/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      operationId: getPortalKbArticle
      summary: Get a knowledge base article (portal)
      description: |
        Returns one article with its tag list. Articles with
        `visibility=private` return 404 (not 403) so the portal cannot
        probe for their existence.

        Requires a `client_portal` user on a PSA or Bundle tier tenant
        plus the `show_kb_section` gate (403 with
        `{"data": {"error": "feature_disabled"}}` when off).
      tags: [Client Portal]
      responses:
        "200":
          description: Article with tags
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      article: *PortalKbArticle
                      tags:
                        type: array
                        items: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/kb/categories:
    get:
      x-stability: internal
      operationId: listPortalKbCategories
      summary: List knowledge base categories (portal)
      description: |
        Returns the flat category list (same data as the MSP view —
        categories are not visibility-gated, only articles are). Build
        the tree client-side via `parent_id`.

        Requires a `client_portal` user on a PSA or Bundle tier tenant
        plus the `show_kb_section` gate (403 with
        `{"data": {"error": "feature_disabled"}}` when off).
      tags: [Client Portal]
      responses:
        "200":
          description: Flat category list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string}
                            parent_id: {type: string, format: uuid, nullable: true}
                            sort_order: {type: integer}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/self:
    get:
      x-stability: internal
      operationId: getPortalSelf
      summary: Get own portal profile
      description: |
        Returns the authenticated contact's own profile and notification
        preferences. There is no `{id}` parameter anywhere on
        /portal/self — cross-contact reads are architecturally
        impossible. Contacts not linked to a user receive 403.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      responses:
        "200":
          description: Own profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &PortalSelf
                    type: object
                    description: Safe subset of the contact row — never includes password hashes or cross-contact data
                    properties:
                      contact_id: {type: string, format: uuid}
                      full_name: {type: string}
                      email:
                        type: string
                        description: Empty string when the contact has no email on file
                      phone: {type: string}
                      title: {type: string}
                      client_id: {type: string, format: uuid, nullable: true}
                      notification_preferences:
                        type: object
                        description: "JSONB preference document; known keys: email_on_ticket_reply, email_on_ticket_status_change, email_on_new_kb_article (all boolean)"
              example:
                data: &PortalSelfExample
                  contact_id: "7c2d1e0f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
                  full_name: "Sarah Pemberton"
                  email: "sarah@greendalesurveyors.example"
                  phone: "+44 1442 000000"
                  title: "Office Manager"
                  client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
                  notification_preferences:
                    email_on_ticket_reply: true
                    email_on_ticket_status_change: false
                    email_on_new_kb_article: false
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: internal
      operationId: updatePortalSelf
      summary: Update own portal profile
      description: |
        Updates the contact's own name, email and/or phone. Accepts
        either `full_name` directly or a `first_name`/`last_name` pair
        (synthesised server-side into `full_name`; full_name wins when
        both are sent). Omitted fields are left unchanged. Responds with
        the canonical post-update profile.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                full_name: {type: string, nullable: true}
                first_name:
                  type: string
                  nullable: true
                  description: Convenience pair; combined with last_name when full_name is absent
                last_name: {type: string, nullable: true}
                email: {type: string, nullable: true}
                phone: {type: string, nullable: true}
            example:
              full_name: "Sarah Pemberton"
              phone: "+44 1442 000001"
      responses:
        "200":
          description: Updated profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PortalSelf
              example:
                data: *PortalSelfExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/self/change-password:
    post:
      x-stability: internal
      operationId: changePortalPassword
      summary: Change own portal password
      description: |
        Changes the calling portal user's password. Requires the current
        password (401 AUTH_FAILED when wrong); the new password must be
        at least 12 characters and differ from the current one.

        Session-only: API keys are rejected (DenyAPIKeys — the same
        account-takeover reasoning as /users/{id}/password). Requires a
        `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: {type: string}
                new_password:
                  type: string
                  minLength: 12
      responses:
        "204":
          description: Password changed
        "400": {$ref: '#/components/responses/ValidationError'}
        "401":
          description: Missing token, or current password incorrect (AUTH_FAILED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/self/notification-preferences:
    put:
      x-stability: internal
      operationId: setPortalNotificationPreferences
      summary: Replace own notification preferences
      description: |
        Replaces the contact's entire notification-preferences JSONB
        document (no partial merge). Accepted keys are whitelisted —
        `email_on_ticket_reply`, `email_on_ticket_status_change`,
        `email_on_new_kb_article` — unknown keys are silently dropped,
        and non-boolean values are rejected with 400.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email_on_ticket_reply: {type: boolean}
                email_on_ticket_status_change: {type: boolean}
                email_on_new_kb_article: {type: boolean}
              additionalProperties: true
            example:
              email_on_ticket_reply: true
              email_on_ticket_status_change: true
              email_on_new_kb_article: false
      responses:
        "204":
          description: Preferences replaced
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/tickets:
    get:
      x-stability: internal
      operationId: listPortalTickets
      summary: List own tickets (portal)
      description: |
        Returns tickets whose `requester_contact_id` is the caller's
        contact — the filter is forced server-side and any user-supplied
        `requester_contact_id` query parameter is ignored. Uses
        `limit`/`offset` paging and returns `{items, total}`. MSP-internal
        AI fields (`ai_sentiment`, `ai_summary`, `ai_summary_at`,
        `ai_category_suggestion`, `ai_draft_citations`) are scrubbed to
        null/omitted on every portal response.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      parameters:
        - name: limit
          in: query
          description: Maximum rows to return
          schema: {type: integer, default: 50}
        - name: offset
          in: query
          description: Rows to skip
          schema: {type: integer, default: 0}
        - name: status
          in: query
          schema:
            type: string
            enum: [new, open, pending, waiting_customer, resolved, closed, cancelled]
      responses:
        "200":
          description: Ticket page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &PortalTicket
                          type: object
                          description: |
                            One row from the tickets table — the same
                            serialisation as the MSP surface, but the
                            ai_* staff-tooling fields are always
                            scrubbed (null/omitted) before reaching a
                            portal contact.
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            ticket_number:
                              type: integer
                              format: int64
                              description: Per-org sequential number; shown as e.g. INC-1042 with the type prefix
                            client_id: {type: string, format: uuid, nullable: true}
                            site_id: {type: string, format: uuid, nullable: true}
                            requester_contact_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Always the caller's own contact id on the portal surface
                            requester_user_id: {type: string, format: uuid, nullable: true}
                            assigned_to: {type: string, format: uuid, nullable: true}
                            assigned_team_id: {type: string, format: uuid, nullable: true}
                            subject: {type: string}
                            description: {type: string}
                            status:
                              type: string
                              enum: [new, open, pending, waiting_customer, resolved, closed, cancelled]
                            impact:
                              type: integer
                              minimum: 1
                              maximum: 5
                              description: ITIL impact, 1 = highest
                            urgency:
                              type: integer
                              minimum: 1
                              maximum: 5
                              description: ITIL urgency, 1 = highest
                            priority_level:
                              type: string
                              enum: [low, medium, high, urgent]
                              description: Derived server-side from impact x urgency; never set directly
                            source_type:
                              type: string
                              enum: [manual, email, alert, api, portal]
                            source_id: {type: string, nullable: true}
                            bundle_id: {type: string, format: uuid, nullable: true}
                            response_due_at: {type: string, format: date-time, nullable: true}
                            resolution_due_at: {type: string, format: date-time, nullable: true}
                            paused_at: {type: string, format: date-time, nullable: true}
                            paused_duration_seconds: {type: integer, format: int64}
                            first_responded_at: {type: string, format: date-time, nullable: true}
                            resolved_at: {type: string, format: date-time, nullable: true}
                            closed_at: {type: string, format: date-time, nullable: true}
                            archived_at: {type: string, format: date-time, nullable: true}
                            properties:
                              type: object
                              description: Per-type dynamic form values (free-form JSON)
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                            needs_triage: {type: boolean}
                            inbound_sender_name: {type: string, nullable: true}
                            inbound_sender_email: {type: string, nullable: true}
                            inbound_auto_submitted: {type: string, nullable: true}
                            inbound_precedence: {type: string, nullable: true}
                            description_html:
                              type: string
                              nullable: true
                              description: Rich HTML body when the ticket originated from inbound email
                            type:
                              type: string
                              enum: [incident, service_request, problem, change, task]
                            category_id: {type: string, format: uuid, nullable: true}
                            sla_plan_id: {type: string, format: uuid, nullable: true}
                            ai_sentiment:
                              nullable: true
                              description: Always null on the portal surface (scrubbed server-side)
                            ai_summary:
                              type: string
                              nullable: true
                              description: Always null on the portal surface (scrubbed server-side)
                            ai_summary_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: Always null on the portal surface (scrubbed server-side)
                            ai_category_suggestion:
                              nullable: true
                              description: Always null on the portal surface (scrubbed server-side)
                            ai_draft_citations:
                              nullable: true
                              description: Always null on the portal surface (scrubbed server-side)
                            parent_id: {type: string, format: uuid, nullable: true}
                            merged_into_id: {type: string, format: uuid, nullable: true}
                            merged_at: {type: string, format: date-time, nullable: true}
                            estimated_hours: {type: number, nullable: true}
                            project_id: {type: string, format: uuid, nullable: true}
                            phase_id: {type: string, format: uuid, nullable: true}
                            requester_name: {type: string, nullable: true, description: List hydration only}
                            client_name: {type: string, nullable: true, description: List hydration only}
                      total:
                        type: integer
                        description: Total matching rows (for offset paging)
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      operationId: createPortalTicket
      summary: Create a ticket (portal)
      description: |
        Creates a ticket on the contact's own behalf:
        `requester_contact_id` is forced to the caller's contact,
        `client_id` is resolved from the contact's record, and
        `source_type` is always `portal`. `impact` and `urgency` default
        to 3 when omitted or zero; `priority_level` is derived
        server-side.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
        The `allow_ticket_creation` branding gate hides the form in the
        UI but is not enforced by this endpoint.
      tags: [Client Portal]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              properties:
                subject: {type: string}
                description: {type: string}
                impact: {type: integer, minimum: 1, maximum: 5, default: 3}
                urgency: {type: integer, minimum: 1, maximum: 5, default: 3}
            example:
              subject: "Printer offline in the front office"
              description: "The Kyocera by reception shows a paper-jam light but there is no jam."
              impact: 3
              urgency: 2
      responses:
        "201":
          description: Ticket created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PortalTicket
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/tickets/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      operationId: getPortalTicket
      summary: Get own ticket detail (portal)
      description: |
        Returns the ticket with its comments and attachments. Tickets
        requested by another contact return 404 (not 403) to avoid
        existence leakage. Internal (staff-only) comments are stripped,
        along with any attachment linked to an internal comment, and the
        ai_* fields are scrubbed.

        `requester_reply_email_enabled` is part of the shared detail
        shape but is never populated on the portal path — it always
        serialises as false here.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      responses:
        "200":
          description: Ticket detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ticket: *PortalTicket
                      comments:
                        type: array
                        description: Public comments only — is_internal rows are stripped server-side
                        items: &PortalTicketComment
                          type: object
                          description: One row from the ticket_comments table (is_internal always false on the portal surface)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            ticket_id: {type: string, format: uuid}
                            author_user_id: {type: string, format: uuid, nullable: true}
                            author_contact_id: {type: string, format: uuid, nullable: true}
                            body: {type: string}
                            body_html: {type: string, nullable: true}
                            is_internal: {type: boolean}
                            source_type:
                              type: string
                              enum: [manual, email, alert, api, portal]
                            source_message_id: {type: string, nullable: true}
                            haraka_mail_log_id: {type: string, nullable: true}
                            delivery_status: {type: string, nullable: true}
                            delivery_diagnostic: {type: string, nullable: true}
                            created_at: {type: string, format: date-time}
                            cc:
                              type: array
                              nullable: true
                              description: Outbound CC recipients
                              items:
                                type: object
                                required: [email]
                                properties:
                                  name: {type: string}
                                  email: {type: string, format: email}
                                  contact_id: {type: string, format: uuid, nullable: true}
                            bcc:
                              type: array
                              nullable: true
                              description: Outbound BCC recipients
                              items:
                                type: object
                                required: [email]
                                properties:
                                  name: {type: string}
                                  email: {type: string, format: email}
                                  contact_id: {type: string, format: uuid, nullable: true}
                      attachments:
                        type: array
                        description: Attachments not linked to internal comments
                        items: &PortalTicketAttachment
                          type: object
                          description: Attachment metadata (bytes live in B2; download via the download endpoint)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            ticket_id: {type: string, format: uuid}
                            comment_id: {type: string, format: uuid, nullable: true}
                            uploaded_by_user_id: {type: string, format: uuid, nullable: true}
                            uploaded_by_contact_id: {type: string, format: uuid, nullable: true}
                            filename: {type: string}
                            mime_type: {type: string}
                            size_bytes: {type: integer, format: int64}
                            sha256: {type: string}
                            created_at: {type: string, format: date-time}
                      requester_reply_email_enabled:
                        type: boolean
                        description: Shared detail-shape field; never populated on the portal path (always false)
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/tickets/{id}/attachments/{aid}/download:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
      - name: aid
        in: path
        required: true
        description: Attachment id
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      operationId: downloadPortalTicketAttachment
      summary: Download a ticket attachment (portal)
      description: |
        Streams the attachment bytes from object storage. Enforces
        ticket ownership AND that the attachment is not linked to an
        internal comment — both failures return 404 (fail closed). The
        response carries `Content-Disposition: attachment` and an
        `X-Content-SHA256` integrity header. Downloads are audit-logged
        with channel=portal.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      responses:
        "200":
          description: Attachment bytes
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Attachment storage not configured (STORAGE_UNCONFIGURED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /portal/tickets/{id}/close:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      operationId: closePortalTicket
      summary: Close own resolved ticket (portal)
      description: |
        Closes the contact's own ticket. The state machine only permits
        closing tickets the MSP has already marked `resolved` — any
        other state returns 409 INVALID_TRANSITION (or ALREADY_CLOSED).
        A portal-driven close silently discards any running MSP-side
        timers. No request body.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      responses:
        "200":
          description: Closed ticket
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PortalTicket
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Ticket cannot be closed from its current state (INVALID_TRANSITION) or is already closed (ALREADY_CLOSED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /portal/tickets/{id}/comments:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      operationId: addPortalTicketComment
      summary: Add a comment to own ticket (portal)
      description: |
        Adds a public comment to the contact's own ticket —
        `is_internal` is always forced false and `source_type` is always
        `portal`. Closed tickets reject new comments with 409
        ALREADY_CLOSED. Tickets owned by another contact return 404.

        Requires a `client_portal` user on a PSA or Bundle tier tenant.
      tags: [Client Portal]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: {type: string}
            example:
              body: "Still happening after the reboot, I'm afraid."
      responses:
        "201":
          description: Comment created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PortalTicketComment
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Ticket is already closed (ALREADY_CLOSED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/ai/tickets/{ticket_id}/draft-reply:
    parameters:
      - name: ticket_id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: psa-tickets:write
      summary: Generate an AI draft reply
      description: |
        RAG-grounded composer prefill with KB citations. When retrieval
        falls below the relevance threshold the response carries
        `fallback: true` with a fixed no-coverage body and the LLM is
        not called (no usage charge). Body is optional; if present it
        must be valid JSON (400 `INVALID_JSON_BODY` otherwise). Requires
        a configured org AI key (412 `AI_KEY_MISSING` /
        `AI_KEY_INVALID`); per-feature daily rate limits return 429 with
        the structured `AI_RATE_LIMIT` error shape (feature, limit,
        used, resets_at inside `error`); administratively disabled
        features return 423 `AI_FEATURE_DISABLED`. PSA/bundle tier +
        MSP-staff only; route 404s on deployments without an AI layer.
      operationId: aiDraftReply
      tags: [AI]
      responses:
        "200":
          description: Draft reply with citations
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body: {type: string, description: Draft reply text}
                      citations:
                        type: array
                        items:
                          type: object
                          properties:
                            article_id: {type: string, format: uuid}
                            slug: {type: string}
                            title: {type: string}
                      fallback:
                        type: boolean
                        description: true = below-threshold retrieval; fixed no-coverage body
        "400":
          description: Invalid ticket_id or non-empty invalid-JSON body
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "412":
          description: Org AI key missing or undecryptable (AI_KEY_MISSING / AI_KEY_INVALID)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "423":
          description: Feature administratively disabled (AI_FEATURE_DISABLED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429":
          description: 'Per-feature daily AI limit hit — structured AI_RATE_LIMIT error (feature, limit, used, resets_at)'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /psa/ai/tickets/{ticket_id}/sentiment:
    parameters:
      - name: ticket_id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: psa-tickets:write
      summary: Score ticket sentiment
      description: |
        Scores any unscored public comments on the ticket (skip rules
        apply — already-scored comments are returned from cache), then
        computes and persists the conversation-level decay-weighted
        rollup. Body optional (must be valid JSON if present). Same AI
        error contract as draft-reply: 412 key missing/invalid, 423
        feature disabled, 429 structured AI_RATE_LIMIT.
      operationId: aiSentiment
      tags: [AI]
      responses:
        "200":
          description: Per-comment scores plus conversation rollup
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      rollup:
                        type: object
                        properties:
                          bucket:
                            type: string
                            enum: [very_negative, negative, neutral, positive, very_positive]
                          confidence:
                            type: number
                            format: float
                            minimum: 0
                            maximum: 1
                          sample_size: {type: integer}
                      scored_comments:
                        type: array
                        items:
                          type: object
                          properties:
                            comment_id: {type: string, format: uuid}
                            bucket:
                              type: string
                              enum: [very_negative, negative, neutral, positive, very_positive]
                            confidence: {type: number, format: float}
                            skipped: {type: boolean}
                            skip_reason: {type: string}
        "400":
          description: Invalid ticket_id or non-empty invalid-JSON body
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "412":
          description: Org AI key missing or undecryptable (AI_KEY_MISSING / AI_KEY_INVALID)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "423":
          description: Feature administratively disabled (AI_FEATURE_DISABLED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429":
          description: 'Per-feature daily AI limit hit — structured AI_RATE_LIMIT error'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /psa/ai/tickets/{ticket_id}/suggest-category:
    parameters:
      - name: ticket_id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: psa-tickets:write
      summary: Suggest ticket categories (AI)
      description: |
        Returns the top-3 category suggestions with confidence scores and
        persists them on the ticket. `name` is omitted when the category
        was deleted between the LLM call and the response. Body optional
        (must be valid JSON if present). Same AI error contract as
        draft-reply: 412 key missing/invalid, 423 feature disabled, 429
        structured AI_RATE_LIMIT.
      operationId: aiSuggestCategory
      tags: [AI]
      responses:
        "200":
          description: Category suggestions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      suggestions:
                        type: array
                        items:
                          type: object
                          properties:
                            category_id: {type: string, format: uuid}
                            confidence: {type: number, format: float}
                            name: {type: string}
        "400":
          description: Invalid ticket_id or non-empty invalid-JSON body
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "412":
          description: Org AI key missing or undecryptable (AI_KEY_MISSING / AI_KEY_INVALID)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "423":
          description: Feature administratively disabled (AI_FEATURE_DISABLED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429":
          description: 'Per-feature daily AI limit hit — structured AI_RATE_LIMIT error'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /psa/ai/tickets/{ticket_id}/summary:
    parameters:
      - name: ticket_id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: psa-tickets:write
      summary: Summarise a ticket (AI)
      description: |
        Generates a 3-5 sentence summary of the ticket conversation and
        persists it on the ticket (`ai_summary` + `ai_summary_at`). Body
        optional (must be valid JSON if present). Same AI error contract
        as draft-reply: 412 key missing/invalid, 423 feature disabled,
        429 structured AI_RATE_LIMIT.
      operationId: aiSummary
      tags: [AI]
      responses:
        "200":
          description: Summary generated and persisted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      summary: {type: string}
                      generated_at: {type: string, format: date-time}
        "400":
          description: Invalid ticket_id or non-empty invalid-JSON body
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "412":
          description: Org AI key missing or undecryptable (AI_KEY_MISSING / AI_KEY_INVALID)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "423":
          description: Feature administratively disabled (AI_FEATURE_DISABLED)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429":
          description: 'Per-feature daily AI limit hit — structured AI_RATE_LIMIT error'
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /psa/billing/asset-assignments:
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: listAssetAssignments
      summary: List asset billing assignments
      description: Unified, paginated list of agents and network devices with their current billing category overlay. Uses limit/offset pagination (not page/per_page).
      tags: [Contracts]
      parameters:
        - name: site_id
          in: query
          schema: {type: string, format: uuid}
        - name: asset_type
          in: query
          schema:
            type: string
            enum: [workstation, standard_server, critical_server, domain_controller, network_device, mobile_device, printer, other, hypervisor]
        - name: search
          in: query
          description: Case-insensitive hostname substring match
          schema: {type: string}
        - name: asset_ids
          in: query
          description: Comma-separated asset UUIDs (max 500) to restrict the result set
          schema: {type: string}
        - name: limit
          in: query
          schema: {type: integer, default: 100, minimum: 1, maximum: 500}
        - name: offset
          in: query
          schema: {type: integer, default: 0, minimum: 0}
      responses:
        "200":
          description: Asset assignment list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            asset_id: {type: string, format: uuid}
                            asset_kind: {type: string, enum: [agent, network_device]}
                            name: {type: string, description: Hostname}
                            site_id: {type: string, format: uuid}
                            site_name: {type: string}
                            asset_type: {type: string}
                            is_billable: {type: boolean}
                            current_category_id: {type: string, format: uuid, nullable: true}
                            current_category_name: {type: string, nullable: true}
                            is_default:
                              type: boolean
                              nullable: true
                              description: True when the assignment came from the asset-type default rather than an explicit operator choice
                      total: {type: integer}
                      limit: {type: integer}
                      offset: {type: integer}
              example:
                data:
                  items:
                    - asset_id: "0f1e2d3c-4b5a-4697-8877-665544332211"
                      asset_kind: "agent"
                      name: "ACME-WS-014"
                      site_id: "9e8d7c6b-5a49-4382-9176-053e4d2c1b0a"
                      site_name: "Acme HQ"
                      asset_type: "workstation"
                      is_billable: true
                      current_category_id: "e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"
                      current_category_name: "Workstations"
                      is_default: true
                  total: 42
                  limit: 100
                  offset: 0
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/asset-assignments/bulk:
    post:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: bulkAssignAssetCategory
      summary: Bulk-assign a billing category
      description: Applies a billing category to up to 500 assets of one kind in a single transaction. History is append-only — the prior assignment row is closed and a new one inserted, so billing snapshots remain auditable.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asset_ids, asset_kind, billing_category_id]
              properties:
                asset_ids:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: {type: string, format: uuid}
                asset_kind: {type: string, enum: [agent, network_device]}
                billing_category_id: {type: string, format: uuid}
            example:
              asset_ids:
                - "0f1e2d3c-4b5a-4697-8877-665544332211"
                - "1a2b3c4d-5e6f-4708-9a0b-1c2d3e4f5a6b"
              asset_kind: "agent"
              billing_category_id: "f8a9b0c1-d2e3-4f4a-b5c6-d7e8f9a0b1c2"
      responses:
        "200":
          description: Assignments applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      applied: {type: integer, description: Number of assets updated}
              example:
                data:
                  applied: 2
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/asset-assignments/{asset_id}:
    parameters:
      - name: asset_id
        in: path
        required: true
        schema: {type: string, format: uuid}
    patch:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: updateAssetAssignment
      summary: Update one asset's billing assignment
      description: Updates asset_type and/or is_billable on the asset and/or reassigns its billing category (append-only history). asset_kind is required to disambiguate agents from network devices.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asset_kind]
              properties:
                asset_kind: {type: string, enum: [agent, network_device]}
                asset_type:
                  type: string
                  enum: [workstation, standard_server, critical_server, domain_controller, network_device, mobile_device, printer, other, hypervisor]
                is_billable: {type: boolean}
                billing_category_id: {type: string, format: uuid}
            example:
              asset_kind: "agent"
              asset_type: "critical_server"
              is_billable: true
              billing_category_id: "a9b0c1d2-e3f4-4a5b-8c6d-7e8f9a0b1c2d"
      responses:
        "200":
          description: Updated assignment row
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      asset_id: {type: string, format: uuid}
                      asset_kind: {type: string, enum: [agent, network_device]}
                      name: {type: string}
                      site_id: {type: string, format: uuid}
                      site_name: {type: string}
                      asset_type: {type: string}
                      is_billable: {type: boolean}
                      current_category_id: {type: string, format: uuid, nullable: true}
                      current_category_name: {type: string, nullable: true}
                      is_default: {type: boolean, nullable: true}
              example:
                data:
                  asset_id: "2b3c4d5e-6f7a-4819-0a1b-2c3d4e5f6a7b"
                  asset_kind: "agent"
                  name: "ACME-DC-01"
                  site_id: "9e8d7c6b-5a49-4382-9176-053e4d2c1b0a"
                  site_name: "Acme HQ"
                  asset_type: "critical_server"
                  is_billable: true
                  current_category_id: "a9b0c1d2-e3f4-4a5b-8c6d-7e8f9a0b1c2d"
                  current_category_name: "Critical servers"
                  is_default: false
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/categories:
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: listBillingCategories
      summary: List billing categories
      description: Returns the tenant's asset billing taxonomy (the 8 system-managed categories plus any custom ones), sorted by sort_order then name. Categories drive asset_count contract lines via asset_filter.billing_category_ids.
      tags: [Contracts]
      responses:
        "200":
          description: Billing category list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        name: {type: string}
                        mapped_asset_type:
                          type: string
                          nullable: true
                          enum: [workstation, standard_server, critical_server, domain_controller, network_device, mobile_device, printer, other, hypervisor]
                          description: Null for custom categories
                        system_managed:
                          type: boolean
                          description: System-managed categories cannot be edited or deleted
                        sort_order: {type: integer}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - id: "e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    name: "Workstations"
                    mapped_asset_type: "workstation"
                    system_managed: true
                    sort_order: 1
                    created_at: "2025-11-01T00:00:00Z"
                    updated_at: "2025-11-01T00:00:00Z"
                  - id: "f8a9b0c1-d2e3-4f4a-b5c6-d7e8f9a0b1c2"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    name: "Co-managed seats"
                    mapped_asset_type: null
                    system_managed: false
                    sort_order: 20
                    created_at: "2026-02-14T10:00:00Z"
                    updated_at: "2026-02-14T10:00:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: createBillingCategory
      summary: Create a custom billing category
      description: Creates a custom (non system-managed) category. 409 CONFLICT on duplicate name.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string, maxLength: 255}
                mapped_asset_type:
                  type: string
                  nullable: true
                  enum: [workstation, standard_server, critical_server, domain_controller, network_device, mobile_device, printer, other, hypervisor]
                sort_order: {type: integer}
            example:
              name: "Co-managed seats"
              sort_order: 20
      responses:
        "201":
          description: Billing category created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The created category (system_managed=false)
              example:
                data:
                  id: "f8a9b0c1-d2e3-4f4a-b5c6-d7e8f9a0b1c2"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  name: "Co-managed seats"
                  mapped_asset_type: null
                  system_managed: false
                  sort_order: 20
                  created_at: "2026-06-10T15:35:00Z"
                  updated_at: "2026-06-10T15:35:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Category name already exists (code CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/categories/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    patch:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: updateBillingCategory
      summary: Update a custom billing category
      description: Partial update of name, mapped_asset_type or sort_order. System-managed categories are immutable (403 FORBIDDEN).
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
                mapped_asset_type:
                  type: string
                  enum: [workstation, standard_server, critical_server, domain_controller, network_device, mobile_device, printer, other, hypervisor]
                sort_order: {type: integer}
            example:
              name: "Co-managed workstation seats"
              sort_order: 21
      responses:
        "200":
          description: Updated category
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The updated category
              example:
                data:
                  id: "f8a9b0c1-d2e3-4f4a-b5c6-d7e8f9a0b1c2"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  name: "Co-managed workstation seats"
                  mapped_asset_type: null
                  system_managed: false
                  sort_order: 21
                  created_at: "2026-06-10T15:35:00Z"
                  updated_at: "2026-06-10T15:40:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403":
          description: System-managed category, or insufficient permission/scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Category name already exists (code CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: deleteBillingCategory
      summary: Delete a custom billing category
      description: System-managed categories cannot be deleted (403). Refused with 409 CONFLICT while the category is referenced by contract line asset filters or current asset assignments.
      tags: [Contracts]
      responses:
        "204":
          description: Billing category deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403":
          description: System-managed category, or insufficient permission/scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Category is in use by contract lines or asset assignments (code CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/category-defaults:
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: listCategoryDefaults
      summary: List asset-type category defaults
      description: Returns the per-tenant default billing category for each asset type. New assets inherit these until explicitly reassigned.
      tags: [Contracts]
      responses:
        "200":
          description: Default mappings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        org_id: {type: string, format: uuid}
                        asset_type:
                          type: string
                          enum: [workstation, standard_server, critical_server, domain_controller, network_device, mobile_device, printer, other, hypervisor]
                        billing_category_id: {type: string, format: uuid}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    asset_type: "workstation"
                    billing_category_id: "e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"
                    created_at: "2025-11-01T00:00:00Z"
                    updated_at: "2025-11-01T00:00:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/category-defaults/{asset_type}:
    parameters:
      - name: asset_type
        in: path
        required: true
        schema:
          type: string
          enum: [workstation, standard_server, critical_server, domain_controller, network_device, mobile_device, printer, other, hypervisor]
    patch:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: updateCategoryDefault
      summary: Set the default category for an asset type
      description: Upserts the default mapping. The category must belong to the tenant.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [billing_category_id]
              properties:
                billing_category_id: {type: string, format: uuid}
            example:
              billing_category_id: "e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"
      responses:
        "200":
          description: Upserted mapping
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      org_id: {type: string, format: uuid}
                      asset_type: {type: string}
                      billing_category_id: {type: string, format: uuid}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
              example:
                data:
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  asset_type: "workstation"
                  billing_category_id: "e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"
                  created_at: "2025-11-01T00:00:00Z"
                  updated_at: "2026-06-10T15:45:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/companies-house/lookup:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      operationId: lookupCompaniesHouse
      summary: Look up a company by number
      description: Returns the canonical Companies House record. The address shape matches the invoice-branding registered_address JSONB so the result can be passed straight to PATCH /psa/billing/invoice-branding.
      tags: [Billing Settings]
      parameters:
        - name: number
          in: query
          required: true
          description: Companies House company number
          schema: {type: string}
      responses:
        "200":
          description: Company record
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      company_name: {type: string}
                      company_number: {type: string}
                      company_status: {type: string}
                      company_type: {type: string}
                      date_of_creation: {type: string}
                      registered_office_address:
                        type: object
                        properties:
                          line1: {type: string}
                          line2: {type: string}
                          city: {type: string}
                          region: {type: string}
                          postal_code: {type: string}
                          country: {type: string}
        "400":
          description: Missing number parameter (code MISSING_NUMBER)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Company not found (code CH_NOT_FOUND)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "412":
          description: GB-registered tenants only (code COMPANIES_HOUSE_NOT_AVAILABLE_FOR_REGION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Lookup not configured on this server (code COMPANIES_HOUSE_NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/companies-house/search:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      operationId: searchCompaniesHouse
      summary: Search Companies House
      description: |
        Server-side proxy for the Companies House company-search typeahead.
        Queries shorter than 2 characters return an empty list. GB tenants
        only (412 otherwise); responds 503 COMPANIES_HOUSE_NOT_CONFIGURED when
        no API key is configured so clients can fall back to manual entry.
      tags: [Billing Settings]
      parameters:
        - name: q
          in: query
          required: true
          description: Search text (minimum 2 characters)
          schema: {type: string}
        - name: limit
          in: query
          description: Max results (1-50)
          schema: {type: integer, default: 10, minimum: 1, maximum: 50}
      responses:
        "200":
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            company_number: {type: string}
                            title: {type: string}
                            company_status: {type: string}
                            company_type: {type: string}
                            address_snippet: {type: string}
                            address:
                              type: object
                              properties:
                                line1: {type: string}
                                line2: {type: string}
                                city: {type: string}
                                region: {type: string}
                                postal_code: {type: string}
                                country: {type: string}
                            date_of_creation: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "412":
          description: GB-registered tenants only (code COMPANIES_HOUSE_NOT_AVAILABLE_FOR_REGION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: Companies House request failed upstream (code CH_UPSTREAM_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Lookup not configured on this server (code COMPANIES_HOUSE_NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/contracts:
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: listContracts
      summary: List contracts
      tags: [Contracts]
      parameters:
        - name: client_id
          in: query
          description: Filter to contracts for one client
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Contract list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        client_id: {type: string, format: uuid}
                        name: {type: string}
                        status:
                          type: string
                          enum: [draft, active, paused, terminated, archived]
                        currency_code: {type: string, example: GBP}
                        start_date: {type: string, format: date-time, nullable: true}
                        end_date: {type: string, format: date-time, nullable: true}
                        notes: {type: string}
                        auto_renew:
                          type: boolean
                          description: When false, billing stops once next_billing_date passes end_date
                        term_months: {type: integer, nullable: true, description: Informational commitment length; the engine reads end_date}
                        prorate_on_end:
                          type: boolean
                          description: When true, the final invoice of a contract ending mid-period is prorated by effective days
                        billing_position:
                          type: string
                          enum: [advance, arrears]
                          description: advance bills at period start; arrears (default) bills after the period ends
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - id: "5e4d3c2b-1a09-4f8e-b7c6-d5e4f3a2b1c0"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                    name: "Acme Engineering - Managed IT"
                    status: "active"
                    currency_code: "GBP"
                    start_date: "2026-01-01T00:00:00Z"
                    end_date: null
                    notes: "Renewal review each January"
                    auto_renew: true
                    term_months: 12
                    prorate_on_end: false
                    billing_position: "arrears"
                    created_at: "2025-12-18T11:00:00Z"
                    updated_at: "2026-04-02T09:15:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: createContract
      summary: Create a contract
      description: |
        Creates a contract in draft status and its billing cycle in one step.
        billing_cadence is a shorthand that sets the cycle's anchor tuple
        (default: monthly, day-of-month 1). When end_date is omitted but
        start_date and term_months are present, end_date is derived.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, name]
              properties:
                client_id: {type: string, format: uuid}
                name: {type: string, maxLength: 255}
                currency_code:
                  type: string
                  description: ISO 4217; defaults to GBP
                start_date: {type: string, format: date, description: "YYYY-MM-DD"}
                end_date: {type: string, format: date, description: "YYYY-MM-DD"}
                notes: {type: string}
                auto_renew: {type: boolean, description: Defaults to true when omitted}
                term_months: {type: integer, minimum: 1, nullable: true}
                billing_cadence:
                  type: string
                  enum: [monthly, weekly, biweekly, quarterly, semi_annual, annual]
                  description: Cycle shorthand; empty/omitted means monthly
                anchor_value:
                  type: integer
                  nullable: true
                  description: Day-of-month (1-28, clamped) for monthly+ cadences, weekday (0=Mon..6=Sun) for weekly; ignored for biweekly
                billing_position:
                  type: string
                  enum: [advance, arrears]
                  description: Defaults to arrears
            example:
              client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
              name: "Acme Engineering - Managed IT"
              currency_code: "GBP"
              start_date: "2026-07-01"
              term_months: 12
              billing_cadence: "monthly"
              anchor_value: 1
              billing_position: "arrears"
              notes: "Signed MSA-2026-014"
      responses:
        "201":
          description: Contract and its billing cycle created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contract:
                        type: object
                        description: The created contract (status draft)
                      cycle:
                        type: object
                        description: The open billing cycle created for the contract
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          contract_id: {type: string, format: uuid}
                          client_id: {type: string, format: uuid}
                          anchor_type:
                            type: string
                            enum: [day_of_month, day_of_week, biweekly, end_of_month]
                          anchor_value: {type: integer}
                          period_months:
                            type: integer
                            description: Cadence multiplier for day_of_month anchors (1/3/6/12); always 1 for day_of_week/biweekly
                          period_start: {type: string, format: date-time}
                          period_end: {type: string, format: date-time}
                          next_billing_date: {type: string, format: date-time}
                          bill_count: {type: integer}
                          closed_at: {type: string, format: date-time, nullable: true}
                          created_at: {type: string, format: date-time}
                          updated_at: {type: string, format: date-time}
              example:
                data:
                  contract:
                    id: "5e4d3c2b-1a09-4f8e-b7c6-d5e4f3a2b1c0"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                    name: "Acme Engineering - Managed IT"
                    status: "draft"
                    currency_code: "GBP"
                    start_date: "2026-07-01T00:00:00Z"
                    end_date: "2027-07-01T00:00:00Z"
                    notes: "Signed MSA-2026-014"
                    auto_renew: true
                    term_months: 12
                    prorate_on_end: false
                    billing_position: "arrears"
                    created_at: "2026-06-10T14:20:00Z"
                    updated_at: "2026-06-10T14:20:00Z"
                  cycle:
                    id: "a0b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    contract_id: "5e4d3c2b-1a09-4f8e-b7c6-d5e4f3a2b1c0"
                    client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                    anchor_type: "day_of_month"
                    anchor_value: 1
                    period_months: 1
                    period_start: "2026-07-01T00:00:00Z"
                    period_end: "2026-08-01T00:00:00Z"
                    next_billing_date: "2026-08-01T00:00:00Z"
                    bill_count: 0
                    closed_at: null
                    created_at: "2026-06-10T14:20:00Z"
                    updated_at: "2026-06-10T14:20:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/contracts/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: getContract
      summary: Get a contract with its lines
      tags: [Contracts]
      responses:
        "200":
          description: Contract and lines
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contract:
                        type: object
                        description: The contract record
                      lines:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            contract_id: {type: string, format: uuid}
                            service_id: {type: string, format: uuid, nullable: true}
                            service_name:
                              type: string
                              nullable: true
                              description: Joined services.name; populated only on list reads
                            name: {type: string}
                            description: {type: string}
                            unit_price_cents:
                              type: integer
                              format: int64
                              description: Unit price in minor currency units (pence). For time_entries lines this is the per-hour rate.
                            quantity: {type: integer}
                            quantity_source:
                              type: string
                              enum: [manual, asset_count, usage_metered, pax8_subscription, time_entries]
                              description: pax8_subscription is set only by the Pax8 sync, never via this API
                            asset_filter:
                              type: object
                              nullable: true
                              description: "Structured filter for asset_count lines: {billing_category_ids: [uuid], site_ids: [uuid], include_inactive: bool}"
                            billing_frequency:
                              type: string
                              enum: [one_off, weekly, biweekly, monthly, quarterly, semi_annual, annual]
                            due_position:
                              type: string
                              enum: [advance, arrears]
                            is_active: {type: boolean}
                            usage_source:
                              type: string
                              enum: [dial9]
                              nullable: true
                              description: Set only when quantity_source = usage_metered
                            pax8_subscription_id: {type: string, nullable: true}
                            pax8_product_id: {type: string, nullable: true}
                            pax8_billing_term: {type: string, nullable: true}
                            pax8_commitment_term: {type: string, nullable: true}
                            pax8_commitment_end_date: {type: string, format: date-time, nullable: true}
                            pax8_state: {type: string, nullable: true}
                            pax8_synced_at: {type: string, format: date-time, nullable: true}
                            pax8_mapping_mode:
                              type: string
                              enum: [auto, manual]
                              description: auto lines are fully sync-managed; manual lines keep operator-set name/price across syncs
                            pax8_prorate_on_change:
                              type: boolean
                              description: When true, billing uses the day-weighted average quantity across the period
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
              example:
                data:
                  contract:
                    id: "5e4d3c2b-1a09-4f8e-b7c6-d5e4f3a2b1c0"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                    name: "Acme Engineering - Managed IT"
                    status: "active"
                    currency_code: "GBP"
                    start_date: "2026-01-01T00:00:00Z"
                    end_date: null
                    notes: ""
                    auto_renew: true
                    term_months: null
                    prorate_on_end: false
                    billing_position: "arrears"
                    created_at: "2025-12-18T11:00:00Z"
                    updated_at: "2026-04-02T09:15:00Z"
                  lines:
                    - id: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e"
                      org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                      contract_id: "5e4d3c2b-1a09-4f8e-b7c6-d5e4f3a2b1c0"
                      service_id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
                      service_name: "Managed Workstation"
                      name: "Managed workstations"
                      description: "All billable workstations on site"
                      unit_price_cents: 4500
                      quantity: 38
                      quantity_source: "asset_count"
                      asset_filter:
                        billing_category_ids: ["e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"]
                        include_inactive: false
                      billing_frequency: "monthly"
                      due_position: "arrears"
                      is_active: true
                      usage_source: null
                      pax8_mapping_mode: "auto"
                      pax8_prorate_on_change: false
                      created_at: "2025-12-18T11:05:00Z"
                      updated_at: "2026-06-01T00:02:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: updateContract
      summary: Update a contract
      description: |
        Partial update — empty/omitted string fields are left untouched.
        Transitioning to terminated or archived closes the open billing cycle
        in the same transaction (paused does not — un-pausing resumes billing).
        Activating a contract is rejected with 409 DUPLICATE_TIME_ENTRIES_LINE
        when it would wake a time & materials line that duplicates another
        active contract's line for the same client and service.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                status:
                  type: string
                  enum: [draft, active, paused, terminated, archived]
                currency_code: {type: string}
                start_date: {type: string, format: date, description: "YYYY-MM-DD"}
                end_date: {type: string, format: date, description: "YYYY-MM-DD"}
                notes: {type: string}
                auto_renew: {type: boolean}
                term_months: {type: integer, minimum: 1, nullable: true}
                prorate_on_end: {type: boolean}
            example:
              status: "active"
      responses:
        "200":
          description: Updated contract
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The updated contract record
              example:
                data:
                  id: "5e4d3c2b-1a09-4f8e-b7c6-d5e4f3a2b1c0"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                  name: "Acme Engineering - Managed IT"
                  status: "active"
                  currency_code: "GBP"
                  start_date: "2026-01-01T00:00:00Z"
                  end_date: null
                  notes: ""
                  auto_renew: true
                  term_months: null
                  prorate_on_end: false
                  billing_position: "arrears"
                  created_at: "2025-12-18T11:00:00Z"
                  updated_at: "2026-06-10T14:25:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Activation would duplicate an active time & materials line (code DUPLICATE_TIME_ENTRIES_LINE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/contracts/{id}/billing-cycle:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: getContractBillingCycle
      summary: Get the contract's open billing cycle
      description: Returns the open billing cycle (anchor, current period window, next billing date). 404 when the contract has no open cycle (e.g. terminated).
      tags: [Contracts]
      responses:
        "200":
          description: Open billing cycle
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      contract_id: {type: string, format: uuid}
                      client_id: {type: string, format: uuid}
                      anchor_type:
                        type: string
                        enum: [day_of_month, day_of_week, biweekly, end_of_month]
                      anchor_value: {type: integer}
                      period_months:
                        type: integer
                        description: 1=monthly, 3=quarterly, 6=semi-annual, 12=annual (day_of_month/end_of_month anchors only)
                      current_period_start: {type: string, format: date, description: "YYYY-MM-DD"}
                      current_period_end: {type: string, format: date, description: "YYYY-MM-DD"}
                      next_billing_date: {type: string, format: date, description: "YYYY-MM-DD"}
                      bill_count:
                        type: integer
                        description: Number of invoices this cycle has generated
              example:
                data:
                  id: "a0b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d"
                  contract_id: "5e4d3c2b-1a09-4f8e-b7c6-d5e4f3a2b1c0"
                  client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                  anchor_type: "day_of_month"
                  anchor_value: 1
                  period_months: 1
                  current_period_start: "2026-06-01"
                  current_period_end: "2026-07-01"
                  next_billing_date: "2026-07-01"
                  bill_count: 5
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: updateContractBillingCycle
      summary: Update the contract's billing cycle anchor
      description: |
        Sets the cycle's (anchor_type, anchor_value, period_months) tuple.
        day_of_month requires anchor_value 1-28 and period_months 1/3/6/12;
        day_of_week requires anchor_value 0-6 and period_months 1; biweekly
        and end_of_month ignore anchor_value (stored as 0). end_of_month
        resolves to the actual last calendar day at compute time.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [anchor_type]
              properties:
                anchor_type:
                  type: string
                  enum: [day_of_month, day_of_week, biweekly, end_of_month]
                anchor_value: {type: integer}
                period_months:
                  type: integer
                  description: Defaults to 1 when omitted
            example:
              anchor_type: "day_of_month"
              anchor_value: 1
              period_months: 3
      responses:
        "200":
          description: Updated anchor tuple
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      anchor_type: {type: string, enum: [day_of_month, day_of_week, biweekly, end_of_month]}
                      anchor_value: {type: integer}
                      period_months: {type: integer}
              example:
                data:
                  anchor_type: "day_of_month"
                  anchor_value: 1
                  period_months: 3
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/contracts/{id}/forecast:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: forecastContractInvoice
      summary: Forecast the next invoice (dry run)
      description: |
        Returns the prospective invoice + line items the next cycle scan would
        produce, computed in a transaction that is rolled back — nothing is
        persisted and the cycle does not advance. Returns 422
        FORECAST_UNAVAILABLE when the contract is not active or has ended.
      tags: [Contracts]
      responses:
        "200":
          description: Prospective invoice (not persisted)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      invoice:
                        type: object
                        description: The prospective invoice (same shape as the invoice record; all *_cents fields are integer pence)
                      items:
                        type: array
                        items:
                          type: object
                          description: Prospective invoice line items
                      period_start: {type: string, format: date}
                      period_end: {type: string, format: date}
                      next_billing_date: {type: string, format: date}
              example:
                data:
                  invoice:
                    id: "00000000-0000-0000-0000-000000000000"
                    client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                    invoice_number: "INV-2026-0061"
                    status: "draft"
                    subtotal_cents: 171000
                    tax_rate_bps: 2000
                    tax_cents: 34200
                    total_cents: 205200
                    currency_code: "GBP"
                  items:
                    - description: "Managed workstations (38 x £45.00)"
                      quantity: 38
                      unit_price_cents: 4500
                      subtotal_cents: 171000
                  period_start: "2026-06-01"
                  period_end: "2026-07-01"
                  next_billing_date: "2026-07-01"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "422":
          description: Forecast unavailable — contract not active, or contract ended (code FORECAST_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/contracts/{id}/generate-invoice:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: generateContractInvoice
      summary: Generate an invoice from the contract now
      description: |
        Runs the recurring billing engine immediately for the contract's open
        cycle and creates a draft invoice — same code path as the daily scan.
        NOT idempotent: each call advances the cycle's next billing date and
        creates a new draft. Issue the draft via POST /psa/billing/invoices/{id}/issue.
      tags: [Contracts]
      responses:
        "201":
          description: Draft invoice created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The created draft invoice (all *_cents fields are integer pence)
              example:
                data:
                  id: "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                  invoice_number: "INV-2026-0061"
                  status: "draft"
                  subtotal_cents: 171000
                  tax_rate_bps: 2000
                  tax_cents: 34200
                  total_cents: 205200
                  currency_code: "GBP"
                  due_date: null
                  notes: ""
                  reverse_charge: false
                  created_at: "2026-06-10T14:40:00Z"
                  updated_at: "2026-06-10T14:40:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No open billing cycle for the contract
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/contracts/{id}/lines:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: addContractLine
      summary: Add a line to a contract
      description: |
        Creates an active contract line. Validation rules:
        asset_count lines require an asset_filter with at least one
        billing_category_id; usage_metered lines require usage_source (only
        "dial9" is supported) and other sources must not carry one;
        time_entries lines require service_id (hours are aggregated per client
        and service; unit_price_cents is the per-hour rate). quantity_source
        "pax8_subscription" is rejected — those lines are created by the Pax8
        sync only. A second active time & materials line for the same client
        and service is rejected with 409 DUPLICATE_TIME_ENTRIES_LINE.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, quantity, unit_price_cents]
              properties:
                service_id: {type: string, format: uuid, nullable: true}
                name: {type: string, maxLength: 255}
                description: {type: string}
                unit_price_cents:
                  type: integer
                  format: int64
                  minimum: 0
                  description: Unit price in minor currency units (pence); per-hour rate for time_entries lines
                quantity: {type: integer, minimum: 1}
                quantity_source:
                  type: string
                  enum: [manual, asset_count, usage_metered, time_entries]
                  description: Defaults to manual
                asset_filter:
                  type: object
                  description: Required when quantity_source = asset_count
                  properties:
                    billing_category_ids:
                      type: array
                      items: {type: string, format: uuid}
                      minItems: 1
                    site_ids:
                      type: array
                      items: {type: string, format: uuid}
                    include_inactive: {type: boolean}
                billing_frequency:
                  type: string
                  enum: [one_off, weekly, biweekly, monthly, quarterly, semi_annual, annual]
                  description: Defaults to monthly
                due_position:
                  type: string
                  enum: [advance, arrears]
                  description: Defaults to arrears
                usage_source:
                  type: string
                  enum: [dial9]
                  description: Required when quantity_source = usage_metered; forbidden otherwise
            example:
              service_id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
              name: "Managed workstations"
              description: "All billable workstations on site"
              unit_price_cents: 4500
              quantity: 1
              quantity_source: "asset_count"
              asset_filter:
                billing_category_ids: ["e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"]
                include_inactive: false
              billing_frequency: "monthly"
              due_position: "arrears"
      responses:
        "201":
          description: Contract line created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The created contract line (is_active=true)
              example:
                data:
                  id: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  contract_id: "5e4d3c2b-1a09-4f8e-b7c6-d5e4f3a2b1c0"
                  service_id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
                  name: "Managed workstations"
                  description: "All billable workstations on site"
                  unit_price_cents: 4500
                  quantity: 1
                  quantity_source: "asset_count"
                  asset_filter:
                    billing_category_ids: ["e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"]
                    include_inactive: false
                  billing_frequency: "monthly"
                  due_position: "arrears"
                  is_active: true
                  pax8_mapping_mode: "auto"
                  pax8_prorate_on_change: false
                  created_at: "2026-06-10T14:30:00Z"
                  updated_at: "2026-06-10T14:30:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Duplicate active time & materials line for this client and service (code DUPLICATE_TIME_ENTRIES_LINE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/contracts/{id}/lines/{lineId}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
      - name: lineId
        in: path
        required: true
        schema: {type: string, format: uuid}
    put:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: updateContractLine
      summary: Update a contract line
      description: |
        Full replace using the same body and validation as line creation. The
        line's is_active flag is preserved server-side (a one_off line
        auto-deactivated after first billing is not silently re-activated).
        The same 409 DUPLICATE_TIME_ENTRIES_LINE guard applies when the
        resulting line is an active time_entries line.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, quantity, unit_price_cents]
              properties:
                service_id: {type: string, format: uuid, nullable: true}
                name: {type: string, maxLength: 255}
                description: {type: string}
                unit_price_cents: {type: integer, format: int64, minimum: 0}
                quantity: {type: integer, minimum: 1}
                quantity_source:
                  type: string
                  enum: [manual, asset_count, usage_metered, time_entries]
                asset_filter: {type: object}
                billing_frequency:
                  type: string
                  enum: [one_off, weekly, biweekly, monthly, quarterly, semi_annual, annual]
                due_position:
                  type: string
                  enum: [advance, arrears]
                usage_source:
                  type: string
                  enum: [dial9]
            example:
              service_id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
              name: "Managed workstations"
              description: "All billable workstations on site"
              unit_price_cents: 4750
              quantity: 1
              quantity_source: "asset_count"
              asset_filter:
                billing_category_ids: ["e7f8a9b0-c1d2-4e3f-a4b5-c6d7e8f9a0b1"]
                include_inactive: false
              billing_frequency: "monthly"
              due_position: "arrears"
      responses:
        "200":
          description: Updated contract line
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The updated contract line
              example:
                data:
                  id: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  contract_id: "5e4d3c2b-1a09-4f8e-b7c6-d5e4f3a2b1c0"
                  service_id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
                  name: "Managed workstations"
                  description: "All billable workstations on site"
                  unit_price_cents: 4750
                  quantity: 1
                  quantity_source: "asset_count"
                  billing_frequency: "monthly"
                  due_position: "arrears"
                  is_active: true
                  pax8_mapping_mode: "auto"
                  pax8_prorate_on_change: false
                  created_at: "2026-06-10T14:30:00Z"
                  updated_at: "2026-06-10T15:00:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Duplicate active time & materials line for this client and service (code DUPLICATE_TIME_ENTRIES_LINE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: deleteContractLine
      summary: Delete a contract line
      description: Lines can only be deleted while the contract is in draft status; otherwise 409 INVALID_STATE. Deactivate the line (PUT) on non-draft contracts instead.
      tags: [Contracts]
      responses:
        "204":
          description: Contract line deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Contract is not in draft status (code INVALID_STATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/contracts/{id}/lines/{lineId}/pax8:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
      - name: lineId
        in: path
        required: true
        schema: {type: string, format: uuid}
    patch:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: patchContractLinePax8Settings
      summary: Update Pax8 settings on a Pax8-mapped line
      description: |
        Sparse update of the per-line Pax8 mapping mode and proration flag.
        At least one field is required. Only lines created by the Pax8 sync
        (pax8_subscription_id set) accept this PATCH; other lines get 400.
      tags: [Contracts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mapping_mode:
                  type: string
                  enum: [auto, manual]
                  description: auto = fully sync-managed; manual = operator-set name/price/contract preserved across syncs
                prorate_on_change:
                  type: boolean
                  description: When true, billing uses the day-weighted average quantity from subscription history
            example:
              mapping_mode: "manual"
              prorate_on_change: true
      responses:
        "200":
          description: Updated Pax8 settings (fields echo the request; unsent fields are null)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      mapping_mode: {type: string, enum: [auto, manual], nullable: true}
                      prorate_on_change: {type: boolean, nullable: true}
              example:
                data:
                  id: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e"
                  mapping_mode: "manual"
                  prorate_on_change: true
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/dial9/config:
    get:
      x-stability: internal
      x-required-scope: psa-telecom:read
      summary: Get Dial 9 configuration
      description: |
        Current Dial 9 config and capability flags — no secret material
        is ever returned; has_rest_credentials / has_sftp_credentials
        signal what is stored. When no row exists, returns a default
        (not-configured) view, not 404. Dial 9 is EU-cell only: on other
        cells these routes do not exist.
      operationId: getDial9Config
      tags: [Telecom]
      responses:
        "200":
          description: Config view
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &Dial9Config
                    type: object
                    properties:
                      org_id: {type: string, format: uuid}
                      base_url: {type: string}
                      sftp_host: {type: string}
                      sftp_port: {type: integer}
                      sftp_username: {type: string, nullable: true}
                      sftp_host_key_fingerprint: {type: string, nullable: true}
                      enabled: {type: boolean}
                      default_markup_percent: {type: number}
                      has_rest_credentials: {type: boolean}
                      has_sftp_credentials: {type: boolean}
                      is_fully_configured: {type: boolean}
                      last_sync_at: {type: string, format: date-time, nullable: true}
                      last_sync_status: {type: string, nullable: true}
                      last_sync_error: {type: string, nullable: true}
                      cdr_synced_through_date: {type: string, format: date-time, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    put:
      x-stability: internal
      x-required-scope: psa-telecom:write
      summary: Save Dial 9 configuration
      description: |
        Sparse update — omitted keys preserve the existing column.
        Plaintext credentials are encrypted server-side; the dedicated
        clear_* booleans null a credential out unambiguously. base_url
        and sftp_host are SSRF-guarded (https, publicly routable).
        Enabling is refused until both REST and SFTP credentials are
        present.
      operationId: putDial9Config
      tags: [Telecom]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                base_url: {type: string}
                api_token: {type: string}
                api_secret: {type: string}
                clear_rest_credentials: {type: boolean}
                sftp_host: {type: string}
                sftp_port: {type: integer, minimum: 1, maximum: 65535}
                sftp_username: {type: string}
                sftp_private_key_pem: {type: string}
                clear_sftp_credentials: {type: boolean}
                enabled: {type: boolean}
                default_markup_percent: {type: number, minimum: 0, maximum: 1000}
      responses:
        "200":
          description: Saved config view
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Dial9Config
        "400":
          description: Validation failure (range, SSRF guard, enable preconditions)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/billing/dial9/invoices/{id}/append-lines:
    post:
      x-stability: internal
      x-required-scope: psa-telecom:write
      summary: Append Dial 9 telecom lines to a draft invoice
      description: |
        Generates one usage line + one DID rental line for the invoice's
        client within the supplied period. With all_unbilled=true every
        not-yet-billed call up to the sync watermark is billed (usage
        only) and the period fields are ignored. The invoice must be in
        draft status. ALSO requires the psa-invoices:write scope — the
        route stacks a second RequirePermission(psa.invoices.edit) check
        on top of psa-telecom:write.
      operationId: appendDial9InvoiceLines
      tags: [Telecom]
      parameters:
        - name: id
          in: path
          required: true
          description: Invoice id
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                period_start: {type: string, description: YYYY-MM-DD (required unless all_unbilled)}
                period_end: {type: string, description: YYYY-MM-DD (required unless all_unbilled)}
                all_unbilled: {type: boolean}
      responses:
        "200":
          description: Lines appended
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      usage_line_id: {type: string, format: uuid, nullable: true}
                      usage_cdr_count: {type: integer}
                      usage_total_seconds: {type: integer}
                      usage_total_cents: {type: integer, format: int64}
                      rental_line_id: {type: string, format: uuid, nullable: true}
                      rental_did_count: {type: integer}
                      rental_total_cents: {type: integer, format: int64}
                      period_start: {type: string, format: date-time}
                      period_end: {type: string, format: date-time}
        "400":
          description: Invalid id or period
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Usage not yet synced for the period (TELECOM_USAGE_NOT_SYNCED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Sync engine not wired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/dial9/keypair:
    post:
      x-stability: internal
      x-required-scope: psa-telecom:write
      summary: Generate a Dial 9 SFTP keypair
      description: |
        Generates a fresh Ed25519 keypair, stores the encrypted private
        key on the org's config row, and returns ONLY the public key
        (paste into Dial 9's reseller portal) and its SHA256 fingerprint.
      operationId: generateDial9Keypair
      tags: [Telecom]
      responses:
        "201":
          description: Keypair generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      authorized_key: {type: string}
                      sha256_fingerprint: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/billing/dial9/numbers:
    get:
      x-stability: internal
      x-required-scope: psa-telecom:read
      summary: List Dial 9 DID inventory
      description: Cached telecom_numbers rows. No pagination — full list.
      operationId: listDial9Numbers
      tags: [Telecom]
      responses:
        "200":
          description: Numbers
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      numbers:
                        type: array
                        nullable: true
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            source: {type: string}
                            vendor_number_id: {type: string, nullable: true}
                            unit_id: {type: string, format: uuid, nullable: true}
                            e164: {type: string}
                            raw_number: {type: string, nullable: true}
                            prefix: {type: string, nullable: true}
                            display_name: {type: string, nullable: true}
                            context: {type: string, nullable: true}
                            client_id: {type: string, format: uuid, nullable: true}
                            monthly_rental_cents: {type: integer}
                            enabled: {type: boolean}
                            last_synced_at: {type: string, format: date-time}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/billing/dial9/numbers/{id}:
    patch:
      x-stability: internal
      x-required-scope: psa-telecom:write
      summary: Assign a Dial 9 number
      description: |
        Sets the client assignment (null/empty clears it) and monthly
        rental price on one DID.
      operationId: assignDial9Number
      tags: [Telecom]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id: {type: string, format: uuid, nullable: true}
                monthly_rental_cents: {type: integer, minimum: 0}
      responses:
        "204":
          description: Updated
        "400":
          description: Invalid id / client_id / rental
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Number not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/dial9/peek-host-key:
    post:
      x-stability: internal
      x-required-scope: psa-telecom:write
      summary: Peek (and optionally pin) the Dial 9 SFTP host key
      description: |
        Fetches the live SFTP host key fingerprint. With ?pin=true the
        fingerprint is also persisted so future SFTP sessions verify
        against it.
      operationId: peekDial9HostKey
      tags: [Telecom]
      parameters:
        - name: pin
          in: query
          description: Set to "true" to persist the fingerprint
          schema: {type: string, enum: ["true"]}
      responses:
        "200":
          description: Live fingerprint
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      fingerprint: {type: string}
                      previous_pin: {type: string, nullable: true}
                      pinned: {type: boolean}
        "400":
          description: SSRF guard rejected the host
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: Host key peek failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/dial9/quarantine:
    get:
      x-stability: internal
      x-required-scope: psa-telecom:read
      summary: List open CDR quarantine rows
      description: Unresolved quarantine rows, oldest first, capped at 200.
      operationId: listDial9Quarantine
      tags: [Telecom]
      responses:
        "200":
          description: Quarantine rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      quarantine:
                        type: array
                        nullable: true
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            cdr_id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            reason:
                              type: string
                              enum: [unmatched_did, unmatched_unit, no_tariff, rate_failed, manual_hold]
                            notes: {type: string, nullable: true}
                            resolved_at: {type: string, format: date-time, nullable: true}
                            resolved_by_user_id: {type: string, format: uuid, nullable: true}
                            resolution:
                              type: string
                              nullable: true
                              enum: [assigned_client, ignored, manual_invoice, reassigned]
                            created_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/billing/dial9/quarantine/{id}/resolve:
    post:
      x-stability: internal
      x-required-scope: psa-telecom:write
      summary: Resolve a CDR quarantine row
      description: |
        Records the operator's resolution. assigned_client / reassigned
        REQUIRE client_id (the CDR is re-attributed and reset to unrated
        so it becomes billable); ignored / manual_invoice REJECT a stray
        client_id. Resolving an already-billed CDR returns 409.
      operationId: resolveDial9Quarantine
      tags: [Telecom]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resolution]
              properties:
                resolution:
                  type: string
                  enum: [assigned_client, ignored, manual_invoice, reassigned]
                notes: {type: string}
                client_id: {type: string, format: uuid, nullable: true}
      responses:
        "204":
          description: Resolved
        "400":
          description: Invalid resolution / client_id rules violated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Quarantine row not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Already resolved, or the CDR is already billed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/dial9/sync:
    post:
      x-stability: internal
      x-required-scope: psa-telecom:write
      summary: Trigger a Dial 9 sync now
      description: |
        Synchronous SyncOrg run (5-minute upper bound). Per-tenant 60s
        cooldown — 429 with Retry-After when triggered again too soon.
        skipped=true in the result means another run held the per-org
        lock.
      operationId: syncDial9Now
      tags: [Telecom]
      responses:
        "200":
          description: Sync result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      org_id: {type: string, format: uuid}
                      units_upserted: {type: integer}
                      numbers_upserted: {type: integer}
                      files_ingested: {type: integer}
                      cdrs_inserted: {type: integer}
                      cdrs_bad_rows: {type: integer}
                      watermark_advanced: {type: string, format: date-time, nullable: true}
                      skipped: {type: boolean}
                      errors:
                        type: array
                        items: {type: string}
                      warnings:
                        type: array
                        items: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429":
          description: Cooldown active (RATE_LIMITED, Retry-After header set)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Sync failed upstream
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Sync engine not wired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/dial9/tariffs:
    get:
      x-stability: internal
      x-required-scope: psa-telecom:read
      summary: List Dial 9 tariffs
      description: Cached call-cost quotes with the per-tariff markup overlay.
      operationId: listDial9Tariffs
      tags: [Telecom]
      responses:
        "200":
          description: Tariffs
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      tariffs:
                        type: array
                        nullable: true
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            destination_name: {type: string}
                            prefix: {type: string}
                            wholesale_per_minute_cents: {type: integer}
                            setup_cents: {type: integer}
                            currency: {type: string}
                            markup_percent: {type: number, nullable: true}
                            markup_per_minute_cents: {type: integer, nullable: true}
                            enabled: {type: boolean}
                            last_quoted_at: {type: string, format: date-time}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/billing/dial9/tariffs/{id}:
    patch:
      x-stability: internal
      x-required-scope: psa-telecom:write
      summary: Set a Dial 9 tariff markup override
      description: |
        Per-tariff markup overrides. clear_markup_percent /
        clear_markup_per_minute_cents null the respective override so the
        org default applies again.
      operationId: setDial9TariffMarkup
      tags: [Telecom]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                markup_percent: {type: number, minimum: -100, maximum: 1000, nullable: true}
                markup_per_minute_cents: {type: integer, minimum: 0, nullable: true}
                clear_markup_percent: {type: boolean}
                clear_markup_per_minute_cents: {type: boolean}
      responses:
        "204":
          description: Updated
        "400":
          description: Range violation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Tariff not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/dial9/units:
    get:
      x-stability: internal
      x-required-scope: psa-telecom:read
      summary: List Dial 9 units
      description: Cached Dial 9 unit inventory. No pagination — full list.
      operationId: listDial9Units
      tags: [Telecom]
      responses:
        "200":
          description: Units
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      units:
                        type: array
                        nullable: true
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            dial9_unit_id: {type: integer}
                            dial9_record_id: {type: string, nullable: true}
                            name: {type: string}
                            identifier: {type: string, nullable: true}
                            customer_reference: {type: string, nullable: true}
                            enabled: {type: boolean}
                            client_id: {type: string, format: uuid, nullable: true}
                            last_synced_at: {type: string, format: date-time}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/billing/dial9/units/{id}:
    patch:
      x-stability: internal
      x-required-scope: psa-telecom:write
      summary: Assign a Dial 9 unit to a client
      description: Sets (or clears, with null/empty) the unit's client assignment.
      operationId: assignDial9Unit
      tags: [Telecom]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id: {type: string, format: uuid, nullable: true}
      responses:
        "204":
          description: Updated
        "400":
          description: Invalid id / client_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Unit not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/invoice-branding:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      operationId: getInvoiceBranding
      summary: Get invoice branding settings
      description: Supplier identity, addresses, logo, accent colour, bank details and boilerplate text rendered on invoice PDFs and emails. invoice_logo_url is a one-hour presigned preview URL.
      tags: [Billing Settings]
      responses:
        "200":
          description: Invoice branding settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      company_legal_name: {type: string, nullable: true}
                      company_number: {type: string, nullable: true, description: "Companies House number, [A-Z0-9]{6,10}"}
                      registered_address:
                        type: object
                        nullable: true
                        properties:
                          line1: {type: string}
                          line2: {type: string}
                          city: {type: string}
                          region: {type: string}
                          postal_code: {type: string}
                          country: {type: string}
                      trading_address:
                        type: object
                        nullable: true
                        description: Same shape as registered_address
                      trading_address_same_as_registered: {type: boolean}
                      supplier_contact:
                        type: object
                        nullable: true
                        properties:
                          email: {type: string}
                          phone: {type: string}
                          website: {type: string}
                      invoice_logo_b2_key: {type: string, nullable: true}
                      invoice_logo_url: {type: string, nullable: true, description: One-hour presigned preview URL}
                      accent_color: {type: string, nullable: true, description: "#RRGGBB"}
                      bank_details:
                        type: object
                        nullable: true
                        properties:
                          account_name: {type: string}
                          sort_code: {type: string}
                          account_number: {type: string}
                          bank_name: {type: string}
                          iban: {type: string}
                          bic: {type: string}
                      payment_terms_text: {type: string, nullable: true}
                      late_payment_clause_text: {type: string, nullable: true}
                      footer_notes: {type: string, nullable: true}
                      reply_to: {type: string, nullable: true}
                      from_name: {type: string, nullable: true}
                      statutory_interest_enabled: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      operationId: updateInvoiceBranding
      summary: Update invoice branding settings
      description: Partial update; omitted fields are untouched, empty strings clear a field. accent_color must be #RRGGBB; company_number must be 6-10 alphanumerics; reply_to and supplier_contact.email must be valid email addresses.
      tags: [Billing Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                company_legal_name: {type: string}
                company_number: {type: string}
                registered_address: {type: object}
                trading_address: {type: object}
                trading_address_same_as_registered: {type: boolean}
                supplier_contact: {type: object}
                accent_color: {type: string}
                bank_details: {type: object}
                payment_terms_text: {type: string}
                late_payment_clause_text: {type: string}
                footer_notes: {type: string}
                reply_to: {type: string}
                from_name: {type: string}
                statutory_interest_enabled: {type: boolean}
            example:
              company_legal_name: "Sample MSP Limited"
              company_number: "12345678"
              accent_color: "#0F4C81"
              statutory_interest_enabled: true
      responses:
        "200":
          description: Updated branding settings (same shape as GET)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        "400":
          description: Validation failure (codes INVALID_HEX, INVALID_COMPANY_NUMBER, INVALID_EMAIL, INVALID_BODY)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoice-branding/logo:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      operationId: uploadInvoiceBrandingLogo
      summary: Upload the invoice logo
      description: Multipart upload (field name "file"; png/jpeg/gif, max 5 MB). The image is fitted to 600x200 preserving aspect ratio and stored as PNG.
      tags: [Billing Settings]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Logo stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      invoice_logo_b2_key: {type: string}
                      invoice_logo_url: {type: string, description: One-hour presigned preview URL}
                      width_px: {type: integer}
                      height_px: {type: integer}
                      size_bytes: {type: integer}
        "400":
          description: Upload failure (codes UPLOAD_TOO_LARGE, MISSING_FILE, UNSUPPORTED_MIME, DECODE_FAILED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Object storage not configured on this deployment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      operationId: deleteInvoiceBrandingLogo
      summary: Remove the invoice logo
      description: Clears the logo reference. The stored object is retained because historical PDF archives may still reference it.
      tags: [Billing Settings]
      responses:
        "204":
          description: Logo reference cleared
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoice-branding/preview:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      operationId: previewInvoiceBrandingPdf
      summary: Preview a sample invoice PDF
      description: Renders a sample invoice with the tenant's current branding (DRAFT watermark, synthetic line items). No DB writes.
      tags: [Billing Settings]
      responses:
        "200":
          description: Sample invoice PDF
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoice-number:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      operationId: getNextInvoiceNumber
      summary: Get the next invoice number
      description: Invoice numbers are INV-{YYYY}-{NNNN}, a gapless per-tenant sequence that resets each calendar year.
      tags: [Billing Settings]
      responses:
        "200":
          description: Next invoice number for the current year
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      next_invoice_number: {type: integer, example: 63}
                      next_invoice_number_formatted: {type: string, example: "INV-2026-0063"}
                      year: {type: integer, example: 2026}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: internal
      x-required-scope: psa-settings:write
      operationId: setNextInvoiceNumber
      summary: Set the next invoice number (forward-only)
      description: Jumps the current year's sequence ahead. Refuses any value that would rewind onto an already-used number (400 COUNTER_NOT_FORWARD).
      tags: [Billing Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [next_invoice_number]
              properties:
                next_invoice_number: {type: integer, minimum: 1}
            example:
              next_invoice_number: 100
      responses:
        "200":
          description: Sequence updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      next_invoice_number: {type: integer}
                      next_invoice_number_formatted: {type: string}
                      year: {type: integer}
        "400":
          description: Validation failure or backwards move (codes VALIDATION_ERROR, COUNTER_NOT_FORWARD)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoices:
    get:
      x-stability: stable
      x-required-scope: psa-invoices:read
      operationId: listInvoices
      summary: List invoices
      description: Returns the tenant's invoices with the client display name. All monetary fields are integers in minor currency units (pence).
      tags: [Invoices]
      parameters:
        - name: client_id
          in: query
          description: Filter to one client
          schema: {type: string, format: uuid}
        - name: status
          in: query
          description: Filter by invoice status
          schema:
            type: string
            enum: [draft, issued, partial, paid, void, written_off, reminder_sent, overdue, final_notice, dunning_suspended]
      responses:
        "200":
          description: Invoice list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        client_id: {type: string, format: uuid}
                        client_name: {type: string}
                        invoice_number: {type: string, example: "INV-2026-0042"}
                        status:
                          type: string
                          enum: [draft, issued, partial, paid, void, written_off, reminder_sent, overdue, final_notice, dunning_suspended]
                        subtotal_cents:
                          type: integer
                          format: int64
                          description: Net subtotal in minor currency units (pence)
                        tax_rate_bps:
                          type: integer
                          description: Tax rate in basis points (2000 = 20% VAT)
                        tax_cents: {type: integer, format: int64}
                        total_cents: {type: integer, format: int64}
                        currency_code: {type: string, example: GBP}
                        issued_at: {type: string, format: date-time, nullable: true}
                        due_date: {type: string, format: date-time, nullable: true}
                        paid_at: {type: string, format: date-time, nullable: true}
                        notes: {type: string}
                        reminder_sent_at: {type: string, format: date-time, nullable: true}
                        final_notice_sent_at: {type: string, format: date-time, nullable: true}
                        suspended_at: {type: string, format: date-time, nullable: true}
                        reactivated_at: {type: string, format: date-time, nullable: true}
                        tax_point_date: {type: string, format: date-time, nullable: true}
                        supply_period_start: {type: string, format: date-time, nullable: true}
                        supply_period_end: {type: string, format: date-time, nullable: true}
                        customer_po: {type: string, nullable: true}
                        contract_reference: {type: string, nullable: true}
                        currency_exchange_rate:
                          type: string
                          nullable: true
                          description: String-encoded decimal to preserve precision
                        currency_exchange_rate_source: {type: string, nullable: true}
                        currency_exchange_rate_date: {type: string, format: date-time, nullable: true}
                        vat_total_gbp_cents: {type: integer, format: int64, nullable: true}
                        reverse_charge: {type: boolean}
                        cash_discount_rate_bps: {type: integer, nullable: true}
                        voided_replaces_invoice_id: {type: string, format: uuid, nullable: true}
                        credit_note_of_invoice_id: {type: string, format: uuid, nullable: true}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - id: "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                    client_name: "Acme Engineering Ltd"
                    invoice_number: "INV-2026-0042"
                    status: "issued"
                    subtotal_cents: 171000
                    tax_rate_bps: 2000
                    tax_cents: 34200
                    total_cents: 205200
                    currency_code: "GBP"
                    issued_at: "2026-06-01T00:05:00Z"
                    due_date: "2026-07-01T00:00:00Z"
                    paid_at: null
                    notes: ""
                    reverse_charge: false
                    created_at: "2026-06-01T00:05:00Z"
                    updated_at: "2026-06-01T00:05:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: createDraftInvoice
      summary: Create a draft invoice
      description: Creates an empty draft invoice for a client. Add lines via POST /psa/billing/invoices/{id}/items, then issue it.
      tags: [Invoices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id]
              properties:
                client_id: {type: string, format: uuid}
                currency_code:
                  type: string
                  description: ISO 4217; defaults to GBP
                due_date: {type: string, format: date, description: "YYYY-MM-DD"}
                notes: {type: string}
            example:
              client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
              currency_code: "GBP"
              due_date: "2026-07-10"
              notes: "Project work - June"
      responses:
        "201":
          description: Draft invoice created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The created draft invoice
              example:
                data:
                  id: "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                  invoice_number: "INV-2026-0062"
                  status: "draft"
                  subtotal_cents: 0
                  tax_rate_bps: 0
                  tax_cents: 0
                  total_cents: 0
                  currency_code: "GBP"
                  due_date: "2026-07-10T00:00:00Z"
                  notes: "Project work - June"
                  reverse_charge: false
                  created_at: "2026-06-10T14:45:00Z"
                  updated_at: "2026-06-10T14:45:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoices/bulk-send:
    post:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: bulkSendInvoices
      summary: Bulk-send invoices by email
      description: |
        Emails each invoice's billing contact a 7-day presigned PDF download
        link (plus a permanent refresh link). Max 200 invoice IDs per request.
        Eligible statuses: issued, partial, reminder_sent, final_notice,
        dunning_suspended — anything else is skipped with a reason, as are
        invoices already sent (idempotency) or clients with no billing contact.
        Per-invoice failures do not abort the batch; every invoice gets a row
        in the response.
      tags: [Invoices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invoice_ids]
              properties:
                invoice_ids:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items: {type: string, format: uuid}
            example:
              invoice_ids:
                - "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70"
                - "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9"
      responses:
        "200":
          description: Per-invoice outcomes
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      results:
                        type: array
                        items:
                          type: object
                          properties:
                            invoice_id: {type: string}
                            invoice_number: {type: string}
                            status: {type: string, enum: [sent, skipped, failed]}
                            reason: {type: string}
                            recipient: {type: string}
                      sent: {type: integer}
                      skipped: {type: integer}
                      failed: {type: integer}
              example:
                data:
                  results:
                    - invoice_id: "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70"
                      invoice_number: "INV-2026-0042"
                      status: "sent"
                      recipient: "accounts@acme-engineering.co.uk"
                    - invoice_id: "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9"
                      invoice_number: "INV-2026-0062"
                      status: "skipped"
                      reason: "invoice status is draft; bulk send only handles issued/partial/dunning states"
                  sent: 1
                  skipped: 1
                  failed: 0
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoices/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: stable
      x-required-scope: psa-invoices:read
      operationId: getInvoice
      summary: Get invoice detail
      description: |
        Returns the invoice with line items, payment link, transaction history,
        and accounting-push status blocks for QuickBooks Online and FreeAgent
        (status one of none/pushed/pending/failed). payment_url is non-empty
        only while the Stripe payment link is active.
      tags: [Invoices]
      responses:
        "200":
          description: Invoice detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      invoice:
                        type: object
                        description: The invoice record (same field set as in the list response, without client_name)
                      client_name: {type: string}
                      tax_label:
                        type: string
                        description: Display label for the header tax line, e.g. "Standard 20% S"
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            invoice_id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            service_id: {type: string, format: uuid, nullable: true}
                            tax_code_id: {type: string, format: uuid, nullable: true}
                            description: {type: string}
                            quantity: {type: integer}
                            unit_price_cents: {type: integer, format: int64}
                            subtotal_cents: {type: integer, format: int64}
                            quantity_snapshot:
                              type: object
                              nullable: true
                              description: Audit snapshot of how the quantity was computed at billing time
                            contract_line_id: {type: string, format: uuid, nullable: true}
                            supply_date: {type: string, format: date-time, nullable: true}
                            service_period_start: {type: string, format: date-time, nullable: true}
                            service_period_end: {type: string, format: date-time, nullable: true}
                            created_at: {type: string, format: date-time}
                      payment_link:
                        type: object
                        nullable: true
                        properties:
                          id: {type: string, format: uuid}
                          invoice_id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          stripe_payment_intent_id: {type: string}
                          stripe_checkout_session_id: {type: string}
                          stripe_payment_link_url: {type: string}
                          expires_at: {type: string, format: date-time, nullable: true}
                          status:
                            type: string
                            enum: [pending, active, completed, expired, cancelled, failed]
                          created_at: {type: string, format: date-time}
                      payment_url:
                        type: string
                        description: Hoisted Stripe Checkout URL; empty unless the link status is active
                      transactions:
                        type: array
                        description: Immutable payment ledger rows for this invoice
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            invoice_id: {type: string, format: uuid, nullable: true}
                            type:
                              type: string
                              enum: [payment, refund, credit_applied, credit_issued, write_off]
                            amount_cents: {type: integer, format: int64}
                            stripe_payment_intent_id: {type: string}
                            recorded_at: {type: string, format: date-time}
                            notes: {type: string}
                            external_id: {type: string}
                            external_system: {type: string}
                            external_payment_method: {type: string}
                            external_synced_at: {type: string, format: date-time, nullable: true}
                      qbo_push:
                        type: object
                        nullable: true
                        properties:
                          status: {type: string, enum: [none, pushed, pending, failed]}
                          external_id: {type: string}
                          external_doc_number: {type: string}
                          synced_at: {type: string, format: date-time, nullable: true}
                          last_error: {type: string}
                          last_error_at: {type: string, format: date-time, nullable: true}
                      qbo_environment:
                        type: string
                        description: "'sandbox', 'production', or '' when not connected"
                      freeagent_push:
                        type: object
                        nullable: true
                        properties:
                          status: {type: string, enum: [none, pushed, pending, failed]}
                          external_id: {type: string}
                          external_doc_number: {type: string}
                          synced_at: {type: string, format: date-time, nullable: true}
                          last_error: {type: string}
                          last_error_at: {type: string, format: date-time, nullable: true}
                      freeagent_environment: {type: string}
              example:
                data:
                  invoice:
                    id: "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    client_id: "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f"
                    invoice_number: "INV-2026-0042"
                    status: "issued"
                    subtotal_cents: 171000
                    tax_rate_bps: 2000
                    tax_cents: 34200
                    total_cents: 205200
                    currency_code: "GBP"
                    issued_at: "2026-06-01T00:05:00Z"
                    due_date: "2026-07-01T00:00:00Z"
                    reverse_charge: false
                    created_at: "2026-06-01T00:05:00Z"
                    updated_at: "2026-06-01T00:05:00Z"
                  client_name: "Acme Engineering Ltd"
                  tax_label: "Standard 20% S"
                  items:
                    - id: "f6a7b8c9-d0e1-4f2a-b3c4-d5e6f7a8b9c0"
                      invoice_id: "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70"
                      org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                      service_id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
                      tax_code_id: "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
                      description: "Managed workstations (38 x £45.00)"
                      quantity: 38
                      unit_price_cents: 4500
                      subtotal_cents: 171000
                      contract_line_id: "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e"
                      created_at: "2026-06-01T00:05:00Z"
                  payment_link: null
                  payment_url: ""
                  transactions: []
                  qbo_push:
                    status: "pushed"
                    external_id: "1042"
                    external_doc_number: "INV-2026-0042"
                    synced_at: "2026-06-01T00:06:10Z"
                  qbo_environment: "production"
                  freeagent_push:
                    status: "none"
                  freeagent_environment: ""
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: updateInvoiceMeta
      summary: Update invoice metadata (drafts only)
      description: |
        Partial update of invoice metadata. Drafts only — once issued, all
        metadata is frozen (400 INVOICE_NOT_DRAFT); the post-issue paths are
        PDF regenerate or void + reissue. Empty strings clear a field.
        supply_period_start and supply_period_end must be set together.
      tags: [Invoices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                customer_po: {type: string}
                contract_reference: {type: string}
                tax_point_date: {type: string, format: date, description: "YYYY-MM-DD; empty clears (auto-derived at issue)"}
                supply_period_start: {type: string, format: date}
                supply_period_end: {type: string, format: date}
                reverse_charge: {type: boolean}
                notes: {type: string}
            example:
              customer_po: "PO-7741"
              contract_reference: "MSA-2026-014"
              supply_period_start: "2026-06-01"
              supply_period_end: "2026-06-30"
      responses:
        "200":
          description: Updated invoice
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The updated invoice record
              example:
                data:
                  id: "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9"
                  invoice_number: "INV-2026-0062"
                  status: "draft"
                  customer_po: "PO-7741"
                  contract_reference: "MSA-2026-014"
                  supply_period_start: "2026-06-01T00:00:00Z"
                  supply_period_end: "2026-06-30T00:00:00Z"
                  subtotal_cents: 171000
                  tax_rate_bps: 2000
                  tax_cents: 34200
                  total_cents: 205200
                  currency_code: "GBP"
                  reverse_charge: false
                  updated_at: "2026-06-10T15:10:00Z"
        "400":
          description: Validation failure, or invoice is no longer a draft (codes VALIDATION_ERROR, INVALID_DATE, INVALID_PERIOD, INVOICE_NOT_DRAFT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoices/{id}/freeagent-push:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: psa-invoices:write
      operationId: pushInvoiceToFreeAgent
      summary: Re-enqueue the FreeAgent push
      description: Manual retry for an invoice whose automatic post-issue FreeAgent push failed. Idempotent — the worker no-ops when the invoice already has a FreeAgent external_id.
      tags: [Invoices]
      responses:
        "202":
          description: Push job enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: enqueued}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: FreeAgent integration not configured (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/invoices/{id}/issue:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: issueInvoice
      summary: Issue a draft invoice
      description: |
        Transitions the invoice draft -> issued: assigns the issue timestamp,
        creates the Stripe payment link, archives the issue-time PDF, and
        enqueues the QuickBooks Online / FreeAgent push when connected.
        Preflight failures return 400: VAT_LINES_MISSING_CODE when the tenant
        is VAT-registered and any line lacks a tax code (UK rule — even
        zero-rated lines need a code), and INVOICE_CURRENCY_UNSUPPORTED when a
        non-GBP invoice would push to a connected GBP QuickBooks realm.
      tags: [Invoices]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                portal_return_url:
                  type: string
                  description: Return URL for the Stripe Checkout session
            example:
              portal_return_url: "https://app.opsmerge.cloud/#/portal/invoices"
      responses:
        "200":
          description: Invoice issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: issued}
              example:
                data:
                  status: "issued"
        "400":
          description: Preflight or transition failure (codes VAT_LINES_MISSING_CODE, INVOICE_CURRENCY_UNSUPPORTED, INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  message: "VAT is enabled but the following lines have no tax code: Out-of-hours network switch replacement. Set a code on every line before issuing."
                  code: "VAT_LINES_MISSING_CODE"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoices/{id}/items:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: addInvoiceItem
      summary: Add a line to a draft invoice
      description: |
        Adds a line and recomputes invoice totals. Drafts only — 409
        INVALID_STATE on any other status (post-issue corrections go through
        void + reissue). When the tenant is VAT-registered and no tax_code_id
        is supplied, the configured default VAT code is applied. unit_price_cents
        must be non-negative (credits belong in the void/credit flow) and the
        line value is capped at 100,000,000,000 minor units.
      tags: [Invoices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [description]
              properties:
                description: {type: string}
                quantity: {type: integer, minimum: 1, description: Defaults to 1}
                unit_price_cents:
                  type: integer
                  format: int64
                  minimum: 0
                  description: Unit price in minor currency units (pence)
                service_id: {type: string, format: uuid, nullable: true}
                tax_code_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Omit to apply the tenant's default VAT code when VAT is enabled
            example:
              description: "Out-of-hours network switch replacement"
              quantity: 2
              unit_price_cents: 22500
              service_id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
      responses:
        "201":
          description: Invoice line created (invoice totals recomputed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The created invoice line
              example:
                data:
                  id: "a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d"
                  invoice_id: "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  service_id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
                  tax_code_id: "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
                  description: "Out-of-hours network switch replacement"
                  quantity: 2
                  unit_price_cents: 22500
                  subtotal_cents: 45000
                  created_at: "2026-06-10T15:15:00Z"
        "400":
          description: Validation failure or cross-tenant reference (codes VALIDATION_ERROR, INVALID_REFERENCE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Invoice is not a draft (code INVALID_STATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoices/{id}/items/{itemId}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
      - name: itemId
        in: path
        required: true
        schema: {type: string, format: uuid}
    patch:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: updateInvoiceItem
      summary: Update a draft invoice line
      description: |
        Sparse update — omitted fields keep their value; subtotal and invoice
        totals are recomputed. Drafts only (409 INVALID_STATE). quantity must
        be >= 1 and unit_price_cents non-negative.
      tags: [Invoices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                description: {type: string}
                quantity: {type: integer, minimum: 1}
                unit_price_cents: {type: integer, format: int64, minimum: 0}
                service_id: {type: string, format: uuid}
                tax_code_id: {type: string, format: uuid}
            example:
              quantity: 3
              unit_price_cents: 21000
      responses:
        "204":
          description: Invoice line updated (no body; invoice totals recomputed)
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Invoice is not a draft (code INVALID_STATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: deleteInvoiceItem
      summary: Delete a draft invoice line
      description: Drafts only (409 INVALID_STATE). Invoice totals are recomputed.
      tags: [Invoices]
      responses:
        "204":
          description: Invoice line deleted (invoice totals recomputed)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Invoice is not a draft (code INVALID_STATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoices/{id}/pdf:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: stable
      x-required-scope: psa-invoices:read
      operationId: downloadInvoicePdf
      summary: Download the invoice PDF
      description: |
        Issued invoices serve the latest immutable archived PDF (headers
        X-Invoice-PDF-Version and X-Invoice-PDF-SHA256 identify the version);
        pass ?version=N for a specific historical archive. Drafts render live.
      tags: [Invoices]
      parameters:
        - name: version
          in: query
          description: Specific archive version to download (positive integer)
          schema: {type: integer, minimum: 1}
      responses:
        "200":
          description: Invoice PDF
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "400":
          description: Invalid version parameter (code INVALID_VERSION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/invoices/{id}/pdf/regenerate:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: regenerateInvoicePdf
      summary: Regenerate the invoice PDF archive
      description: |
        Renders a fresh PDF with current branding and stores it as a new
        archive version (previous versions remain retrievable via
        GET /pdf?version=N). Issued invoices only — drafts return 400
        INVOICE_NOT_ISSUED (drafts use the live GET /pdf preview).
      tags: [Invoices]
      responses:
        "200":
          description: New archive version written
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      version: {type: integer}
                      sha256: {type: string}
                      size_bytes: {type: integer}
              example:
                data:
                  version: 2
                  sha256: "9f2c1d3e4b5a69788796a5b4c3d2e1f00112233445566778899aabbccddeeff"
                  size_bytes: 48211
        "400":
          description: Invoice is still a draft (code INVOICE_NOT_ISSUED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Object storage not configured on this deployment
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/invoices/{id}/qbo-push:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: psa-invoices:write
      operationId: pushInvoiceToQbo
      summary: Re-enqueue the QuickBooks Online push
      description: Manual retry for an invoice whose automatic post-issue QBO push failed. Idempotent — the worker no-ops when the invoice already has a QBO external_id.
      tags: [Invoices]
      responses:
        "202":
          description: Push job enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: enqueued}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: QuickBooks integration not configured (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/billing/invoices/{id}/void:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: stable
      x-required-scope: psa-invoices:write
      operationId: voidInvoice
      summary: Void an invoice
      description: |
        Voids the invoice and runs the full reversal chain: cancels payment
        links, reverses applied credits, releases usage records and rolls back
        recurring service periods so the billable artefacts are not consumed.
        Refused with 422 INVOICE_HAS_PAYMENTS when payments have been recorded.
        When connected, a QuickBooks/FreeAgent void job is enqueued post-commit.
      tags: [Invoices]
      responses:
        "200":
          description: Invoice voided
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: void}
              example:
                data:
                  status: "void"
        "400":
          description: Invalid state transition (code INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "422":
          description: Invoice has recorded payments and cannot be voided (code INVOICE_HAS_PAYMENTS)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/run:
    get:
      x-stability: internal
      x-required-scope: psa-invoices:read
      operationId: getBillingRun
      summary: Billing run review (drafts grouped by client)
      description: |
        Read-only review surface for the Billing Run page: all draft invoices
        created within a calendar month, grouped by client and attributed to
        the originating contract where the draft came from the recurring
        engine. Authorisation still goes through per-invoice /issue.
      tags: [Invoices]
      parameters:
        - name: period
          in: query
          description: Calendar month as YYYY-MM; defaults to the current month (UTC)
          schema: {type: string, example: "2026-06"}
      responses:
        "200":
          description: Drafts grouped by client
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      period_start: {type: string, format: date-time}
                      period_end: {type: string, format: date-time}
                      clients:
                        type: array
                        items:
                          type: object
                          properties:
                            client_id: {type: string, format: uuid}
                            client_name: {type: string}
                            draft_count: {type: integer}
                            total_cents: {type: integer, format: int64}
                            currency_code: {type: string}
                            drafts:
                              type: array
                              items:
                                type: object
                                properties:
                                  invoice_id: {type: string, format: uuid}
                                  invoice_number: {type: string}
                                  total_cents: {type: integer, format: int64}
                                  currency_code: {type: string}
                                  created_at: {type: string, format: date-time}
                                  contract_id: {type: string, format: uuid, nullable: true}
                                  contract_name: {type: string}
                      summary:
                        type: object
                        properties:
                          client_count: {type: integer}
                          draft_count: {type: integer}
                          total_cents: {type: integer, format: int64}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/service-categories:
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: listBillingServiceCategories
      summary: List service categories
      description: Returns every service-catalogue category for the tenant, ordered by display_order.
      tags: [Billing Services]
      responses:
        "200":
          description: Service category list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        name: {type: string}
                        description: {type: string}
                        display_order: {type: integer}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - id: "0d4f8e7a-1b2c-4d3e-9f80-6a5b4c3d2e1f"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    name: "Managed Services"
                    description: "Recurring per-seat and per-device support plans"
                    display_order: 1
                    created_at: "2026-01-12T09:30:00Z"
                    updated_at: "2026-01-12T09:30:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: createBillingServiceCategory
      summary: Create a service category
      tags: [Billing Services]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string, maxLength: 255}
                description: {type: string}
                display_order: {type: integer}
            example:
              name: "Cloud Subscriptions"
              description: "Microsoft 365 and other resold licences"
              display_order: 2
      responses:
        "201":
          description: Service category created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      name: {type: string}
                      description: {type: string}
                      display_order: {type: integer}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
              example:
                data:
                  id: "f2a1b0c9-d8e7-4f65-a4b3-c2d1e0f9a8b7"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  name: "Cloud Subscriptions"
                  description: "Microsoft 365 and other resold licences"
                  display_order: 2
                  created_at: "2026-06-10T14:05:00Z"
                  updated_at: "2026-06-10T14:05:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/services:
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: listBillingServices
      summary: List services in the catalogue
      description: |
        Returns the tenant's billable service catalogue. Filter precedence:
        `only_inactive=true` returns archived services only; otherwise
        `active_only=true` returns active services only; default returns all.
      tags: [Billing Services]
      parameters:
        - name: active_only
          in: query
          description: Return active services only (used by pickers)
          schema: {type: boolean}
        - name: only_inactive
          in: query
          description: Return archived (is_active=false) services only; takes precedence over active_only
          schema: {type: boolean}
      responses:
        "200":
          description: Service list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        category_id: {type: string, format: uuid, nullable: true}
                        name: {type: string}
                        description: {type: string}
                        default_rate_cents:
                          type: integer
                          format: int64
                          description: Default unit rate in minor currency units (pence)
                        is_active: {type: boolean}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    category_id: "0d4f8e7a-1b2c-4d3e-9f80-6a5b4c3d2e1f"
                    name: "Managed Workstation"
                    description: "Per-device monitoring, patching and helpdesk"
                    default_rate_cents: 4500
                    is_active: true
                    created_at: "2026-01-12T09:31:00Z"
                    updated_at: "2026-03-02T16:20:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: createBillingService
      summary: Create a service
      description: |
        Adds a service to the catalogue. When a QuickBooks Online connection
        exists, a matching QBO Item is created asynchronously after commit.
      tags: [Billing Services]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                category_id: {type: string, format: uuid, nullable: true}
                name: {type: string, maxLength: 255}
                description: {type: string}
                default_rate_cents:
                  type: integer
                  format: int64
                  minimum: 0
                  description: Default unit rate in minor currency units (pence)
                is_active: {type: boolean}
            example:
              category_id: "0d4f8e7a-1b2c-4d3e-9f80-6a5b4c3d2e1f"
              name: "Managed Server"
              description: "Per-server monitoring, patching and backup checks"
              default_rate_cents: 8500
              is_active: true
      responses:
        "201":
          description: Service created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      category_id: {type: string, format: uuid, nullable: true}
                      name: {type: string}
                      description: {type: string}
                      default_rate_cents: {type: integer, format: int64}
                      is_active: {type: boolean}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
              example:
                data:
                  id: "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  category_id: "0d4f8e7a-1b2c-4d3e-9f80-6a5b4c3d2e1f"
                  name: "Managed Server"
                  description: "Per-server monitoring, patching and backup checks"
                  default_rate_cents: 8500
                  is_active: true
                  created_at: "2026-06-10T14:06:00Z"
                  updated_at: "2026-06-10T14:06:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/services/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: getBillingService
      summary: Get a service by ID
      tags: [Billing Services]
      responses:
        "200":
          description: Service details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      category_id: {type: string, format: uuid, nullable: true}
                      name: {type: string}
                      description: {type: string}
                      default_rate_cents: {type: integer, format: int64}
                      is_active: {type: boolean}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
              example:
                data:
                  id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  category_id: "0d4f8e7a-1b2c-4d3e-9f80-6a5b4c3d2e1f"
                  name: "Managed Workstation"
                  description: "Per-device monitoring, patching and helpdesk"
                  default_rate_cents: 4500
                  is_active: true
                  created_at: "2026-01-12T09:31:00Z"
                  updated_at: "2026-03-02T16:20:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: updateBillingService
      summary: Update a service
      description: Full replace of the editable fields. Set is_active=false to archive a service that is still referenced by invoices or contracts.
      tags: [Billing Services]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                category_id: {type: string, format: uuid, nullable: true}
                name: {type: string, maxLength: 255}
                description: {type: string}
                default_rate_cents: {type: integer, format: int64, minimum: 0}
                is_active: {type: boolean}
            example:
              category_id: "0d4f8e7a-1b2c-4d3e-9f80-6a5b4c3d2e1f"
              name: "Managed Workstation"
              description: "Per-device monitoring, patching and helpdesk"
              default_rate_cents: 4750
              is_active: true
      responses:
        "200":
          description: Service updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      category_id: {type: string, format: uuid, nullable: true}
                      name: {type: string}
                      description: {type: string}
                      default_rate_cents: {type: integer, format: int64}
                      is_active: {type: boolean}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
              example:
                data:
                  id: "3c2b1a09-8f7e-4d6c-b5a4-93827160fedc"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  category_id: "0d4f8e7a-1b2c-4d3e-9f80-6a5b4c3d2e1f"
                  name: "Managed Workstation"
                  description: "Per-device monitoring, patching and helpdesk"
                  default_rate_cents: 4750
                  is_active: true
                  created_at: "2026-01-12T09:31:00Z"
                  updated_at: "2026-06-10T14:10:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: deleteBillingService
      summary: Delete a service
      description: |
        Hard-deletes a service that is not referenced anywhere. Refused with
        409 SERVICE_IN_USE (details carry the usage counts) when the service is
        referenced by any invoice line or by an active/draft contract line —
        archive it instead (PUT with is_active=false) to retain the audit trail.
      tags: [Billing Services]
      responses:
        "204":
          description: Service deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Service is in use (code SERVICE_IN_USE; error.details carries the usage counts)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  message: "This service is in use and cannot be deleted. Archive it instead to retain the audit trail."
                  code: "SERVICE_IN_USE"
                  details:
                    invoice_item_count: 14
                    contract_line_count: 3
                    has_issued_reference: true
                    last_invoiced_at: "2026-05-31T00:05:12Z"
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/services/{id}/usage:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: getBillingServiceUsage
      summary: Get service reference counts
      description: |
        Returns where a service is referenced (invoice lines, active/draft
        contract lines) so a client can pre-check before attempting delete and
        offer "archive instead" when usage is non-zero. has_issued_reference
        distinguishes draft-only references (recoverable) from references on
        already-issued invoices (permanent — the audit trail keeps them).
      tags: [Billing Services]
      responses:
        "200":
          description: Usage counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      invoice_item_count: {type: integer}
                      contract_line_count:
                        type: integer
                        description: References from contract lines on active or draft contracts
                      has_issued_reference:
                        type: boolean
                        description: True when any referencing invoice is past draft (not draft/void/written_off)
                      last_invoiced_at: {type: string, format: date-time, nullable: true}
              example:
                data:
                  invoice_item_count: 14
                  contract_line_count: 3
                  has_issued_reference: true
                  last_invoiced_at: "2026-05-31T00:05:12Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/settings:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      operationId: getBillingSettings
      summary: Get invoicing defaults
      description: Returns the tenant's invoicing defaults (currency, payment terms) and the default tax rate. Falls back to GBP / 30 days when no config row exists.
      tags: [Billing Settings]
      responses:
        "200":
          description: Billing settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      default_currency_code: {type: string, example: GBP}
                      payment_terms_days: {type: integer, example: 30}
                      default_tax_rate:
                        type: object
                        nullable: true
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          rate_bps: {type: integer, description: Basis points (2000 = 20% VAT)}
                          label: {type: string}
                          is_default: {type: boolean}
                          effective_from: {type: string, format: date-time}
                          created_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: internal
      x-required-scope: psa-settings:write
      operationId: saveBillingSettings
      summary: Save invoicing defaults
      tags: [Billing Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                default_currency_code: {type: string, description: ISO 4217}
                payment_terms_days: {type: integer, minimum: 0}
            example:
              default_currency_code: "GBP"
              payment_terms_days: 30
      responses:
        "200":
          description: Settings saved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: saved}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/tax-rates:
    get:
      x-stability: stable
      x-required-scope: psa-contracts:read
      operationId: listTaxRates
      summary: List tax rates
      description: Returns the tenant's legacy flat tax rates. Rates are basis points (2000 = 20% UK VAT). VAT-registered tenants normally use per-line tax codes instead (see vat-settings).
      tags: [Billing Settings]
      responses:
        "200":
          description: Tax rate list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        rate_bps: {type: integer, description: Basis points (2000 = 20% VAT)}
                        label: {type: string}
                        is_default: {type: boolean}
                        effective_from: {type: string, format: date-time}
                        created_at: {type: string, format: date-time}
              example:
                data:
                  - id: "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e"
                    org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                    rate_bps: 2000
                    label: "VAT 20%"
                    is_default: true
                    effective_from: "2026-01-01T00:00:00Z"
                    created_at: "2026-01-01T09:00:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-contracts:write
      operationId: createTaxRate
      summary: Create a tax rate
      description: Creates a tax rate. Setting is_default=true unsets the previous default. Omitted label defaults to "{rate}% tax"; omitted effective_from defaults to now.
      tags: [Billing Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rate_bps]
              properties:
                rate_bps:
                  type: integer
                  minimum: 0
                  maximum: 100000
                  description: Basis points (2000 = 20% VAT)
                label: {type: string}
                is_default: {type: boolean}
                effective_from: {type: string, format: date, description: "YYYY-MM-DD"}
            example:
              rate_bps: 2000
              label: "VAT 20%"
              is_default: true
              effective_from: "2026-04-01"
      responses:
        "201":
          description: Tax rate created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      rate_bps: {type: integer}
                      label: {type: string}
                      is_default: {type: boolean}
                      effective_from: {type: string, format: date-time}
                      created_at: {type: string, format: date-time}
              example:
                data:
                  id: "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"
                  org_id: "7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8"
                  rate_bps: 2000
                  label: "VAT 20%"
                  is_default: true
                  effective_from: "2026-04-01T00:00:00Z"
                  created_at: "2026-06-10T15:30:00Z"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/vat-settings:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      operationId: getVatSettings
      summary: Get VAT settings
      description: Returns the tenant's VAT registration state, default tax code, the sales-applicable tax codes for the dropdown, and which accounting provider is connected.
      tags: [Billing Settings]
      responses:
        "200":
          description: VAT settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      vat_enabled: {type: boolean}
                      vat_number: {type: string}
                      vat_default_tax_code_id: {type: string, format: uuid, nullable: true}
                      available_codes:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string}
                            name: {type: string}
                            rate_bps: {type: integer, nullable: true, description: Basis points (2000 = 20% VAT)}
                            external_id: {type: string}
                      has_qbo_connection: {type: boolean}
                      has_freeagent_connection: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      operationId: updateVatSettings
      summary: Update VAT settings
      description: |
        Partial update. Enabling VAT requires both a VAT registration number
        and a default tax code (400 VAT_NUMBER_REQUIRED / VAT_CODE_REQUIRED),
        and is restricted to GB tenants (412 VAT_NOT_AVAILABLE_FOR_REGION).
      tags: [Billing Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                vat_enabled: {type: boolean}
                vat_number: {type: string}
                vat_default_tax_code_id:
                  type: string
                  description: Tax code UUID; empty string clears it
            example:
              vat_enabled: true
              vat_number: "GB123456789"
              vat_default_tax_code_id: "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
      responses:
        "204":
          description: VAT settings saved
        "400":
          description: Validation failure (codes VAT_NUMBER_REQUIRED, VAT_CODE_REQUIRED, VALIDATION_ERROR, INVALID_BODY)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "412":
          description: VAT is only available for UK-registered tenants (code VAT_NOT_AVAILABLE_FOR_REGION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/billing/webhook/platform:
    post:
      x-stability: internal
      operationId: receiveStripePlatformWebhook
      summary: Stripe platform billing webhook
      description: |
        Receives Stripe events for OpsMerge's own subscription billing (platform
        Stripe account — never MSP-to-client billing). Authentication is the
        standard Stripe webhook scheme: the `Stripe-Signature` header
        (`t=<ts>,v1=<hex HMAC-SHA256>`) verified against
        `RMM_STRIPE_WEBHOOK_SECRET` by the Stripe SDK's ConstructEvent.
        `checkout.session.completed` and `invoice.payment_succeeded` are
        handled inline (tenant provisioning / dunning reactivation); other
        events are enqueued to River for async processing. Idempotent via the
        processed_stripe_events guard; 500 responses make Stripe retry.
      tags: ["Webhooks (Inbound)"]
      security: []
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          description: "Stripe webhook signature header: `t=<timestamp>,v1=<hex HMAC-SHA256>` over `<timestamp>.<raw body>`."
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Stripe Event object (raw bytes are signature-verified before parsing).
      responses:
        "200":
          description: Event verified and processed (or enqueued).
        "401":
          description: Missing `Stripe-Signature` header or signature verification failed (plain text).
        "500":
          description: Inline handling failed — Stripe will retry; idempotency guards make redelivery safe.
        "503":
          description: No platform Stripe provider configured (missing secret key / webhook secret) — refused so retries surface the gap.
  /psa/canned_responses:
    get:
      x-stability: internal
      x-required-scope: psa-canned-responses:read
      summary: List canned responses
      description: |
        Returns the canned responses visible to the calling user — all
        shared (is_shared=true) entries plus the caller's private ones.
      operationId: listCannedResponses
      tags: [Canned Responses]
      responses:
        "200":
          description: Canned response list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &CannedResponse
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string}
                            body: {type: string}
                            body_html: {type: string, nullable: true}
                            category: {type: string, nullable: true}
                            created_by: {type: string, format: uuid}
                            last_used_at: {type: string, format: date-time, nullable: true}
                            is_shared: {type: boolean}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-canned-responses:write
      summary: Create a canned response
      description: |
        Name must be unique (409 DUPLICATE). Bodies support the
        {{contact.name}}/{{ticket.subject}}-style variable substitution
        applied at render time.
      operationId: createCannedResponse
      tags: [Canned Responses]
      requestBody:
        required: true
        content:
          application/json:
            schema: &CannedResponseRequest
              type: object
              properties:
                name: {type: string, nullable: true}
                body: {type: string, nullable: true}
                body_html: {type: string, nullable: true}
                category: {type: string, nullable: true}
                is_shared: {type: boolean, nullable: true}
      responses:
        "201":
          description: Canned response created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *CannedResponse
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Name already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/canned_responses/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: internal
      x-required-scope: psa-canned-responses:write
      summary: Update a canned response
      description: |
        Private canned responses (is_shared=false) may only be modified
        by their author or a superuser (403 otherwise); shared ones by
        anyone holding the edit permission.
      operationId: updateCannedResponse
      tags: [Canned Responses]
      requestBody:
        required: true
        content:
          application/json:
            schema: *CannedResponseRequest
      responses:
        "200":
          description: Updated canned response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *CannedResponse
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Name already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-canned-responses:write
      summary: Delete a canned response
      description: |
        Private canned responses may only be deleted by their author or a
        superuser (403 otherwise).
      operationId: deleteCannedResponse
      tags: [Canned Responses]
      responses:
        "204":
          description: Canned response deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/canned_responses/{id}/preview:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: internal
      x-required-scope: psa-canned-responses:read
      summary: Preview a rendered canned response
      description: |
        Renders the canned response's variable substitutions against a
        live ticket (`ticket_id`) or an explicit `override` context.
        Empty body is allowed (renders with an empty context). Also
        stamps last_used_at (best-effort).
      operationId: previewCannedResponse
      tags: [Canned Responses]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ticket_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Hydrate the substitution context from this ticket
                override:
                  type: object
                  nullable: true
                  description: |
                    Explicit substitution context (beats ticket_id). Field
                    names are matched case-insensitively. Sub-objects:
                    Contact {Name, FirstName, Email}, Ticket {ID, Subject,
                    Status}, Client {Name}, User {Name, Email}.
      responses:
        "200":
          description: Rendered body (+ body_html when the canned response has one)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      body: {type: string}
                      body_html: {type: string, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/catalogue/search:
    get:
      x-stability: beta
      x-required-scope: psa-products:read
      summary: Search the unified product catalogue
      description: |
        Unified search for the quote/invoice line picker. Returns two
        panes in one payload: `products` (active rows from the curated
        catalogue, matched on name/SKU substring) and `feeds` (in-stock
        supplier feed items, matched on name). `include_feeds=false`
        skips the feed search and returns an empty `feeds` array. With
        no `q` the first `limit` active products are returned. No
        offset paging and no totals — this is a picker, not a browse
        surface; use `GET /psa/products` and
        `GET /psa/suppliers/{id}/feed` for full listings.
      operationId: searchCatalogue
      tags: [Catalogue]
      parameters:
        - name: q
          in: query
          description: Substring match on product name/SKU and feed item name
          schema: {type: string}
        - name: include_feeds
          in: query
          description: Set to false to skip the supplier-feed pane (default true)
          schema: {type: boolean, default: true}
        - name: limit
          in: query
          description: "Max rows per pane, 1-100; out-of-range values fall back to the default"
          schema: {type: integer, default: 25, maximum: 100}
      responses:
        "200":
          description: Picker payload with curated products and supplier feed items
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      products:
                        type: array
                        items: &Product
                          type: object
                          description: One row from the products table (curated catalogue)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            sku:
                              type: string
                              description: Operator-facing identifier; distinct from supplier SKUs
                            name: {type: string}
                            description: {type: string}
                            category:
                              type: string
                              description: "Operator-defined grouping string, e.g. Laptops"
                            manufacturer: {type: string}
                            pricing_mode:
                              type: string
                              enum: [fixed, markup]
                              description: "fixed = sell price frozen on the row; markup = sell = cost x (1 + markup_bps/10000), recalculated on feed cost refresh"
                            default_cost_cents:
                              type: integer
                              format: int64
                              description: What the MSP pays, in minor units (pence)
                            default_sell_cents:
                              type: integer
                              format: int64
                              description: Sell price in minor units; authoritative when pricing_mode=fixed
                            markup_bps:
                              type: integer
                              nullable: true
                              description: "Markup in basis points (2000 = 20%); authoritative when pricing_mode=markup. null means 0%"
                            currency_code: {type: string, example: GBP}
                            tax_code_id: {type: string, format: uuid, nullable: true}
                            track_as_asset:
                              type: boolean
                              description: When true, quote accept spawns one procured-item stub per quantity unit
                            external_id:
                              type: string
                              description: QuickBooks Online Item id once linked; omitted when unlinked
                            external_system:
                              type: string
                              description: "quickbooks_online once linked; omitted otherwise"
                            external_synced_at: {type: string, format: date-time, nullable: true}
                            qbo_push_pending: {type: boolean}
                            qbo_push_last_error:
                              type: string
                              description: Human-readable reason for the most recent QBO push failure; omitted on success
                            is_active: {type: boolean}
                            properties:
                              type: object
                              description: Free-form JSON bag
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                            created_by: {type: string, format: uuid, nullable: true}
                            supplier_skus:
                              type: array
                              description: Bridged supplier SKU links; only populated on GET /psa/products/{id}
                              items: &SupplierSKU
                                type: object
                                description: One row from product_supplier_skus (product-to-supplier-SKU bridge)
                                properties:
                                  id: {type: string, format: uuid}
                                  org_id: {type: string, format: uuid}
                                  product_id: {type: string, format: uuid}
                                  supplier_id: {type: string, format: uuid}
                                  supplier_name:
                                    type: string
                                    description: Joined from suppliers for display; omitted when not hydrated
                                  supplier_sku: {type: string}
                                  supplier_cost_cents:
                                    type: integer
                                    format: int64
                                    nullable: true
                                    description: Last-known cost from the feed, in minor units
                                  currency_code: {type: string, example: GBP}
                                  in_stock:
                                    type: boolean
                                    nullable: true
                                    description: Last-known availability; null = unknown
                                  stock_quantity: {type: integer, nullable: true}
                                  last_refreshed_at:
                                    type: string
                                    format: date-time
                                    nullable: true
                                    description: When a feed refresh last touched this row
                                  is_preferred:
                                    type: boolean
                                    description: At most one preferred SKU per product
                                  created_at: {type: string, format: date-time}
                                  updated_at: {type: string, format: date-time}
                      feeds:
                        type: array
                        items: &FeedItem
                          type: object
                          description: One row from supplier_feed_items (raw supplier catalogue firehose)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            supplier_id: {type: string, format: uuid}
                            supplier_name:
                              type: string
                              description: Joined from suppliers for display
                            supplier_sku: {type: string}
                            name: {type: string}
                            description: {type: string}
                            manufacturer: {type: string}
                            category: {type: string}
                            cost_cents:
                              type: integer
                              format: int64
                              nullable: true
                              description: Trade cost in minor units; null when the feed had no cost column
                            currency_code: {type: string, example: GBP}
                            in_stock: {type: boolean, nullable: true}
                            stock_quantity: {type: integer, nullable: true}
                            raw:
                              type: object
                              description: Original unmapped CSV/API columns; omitted when empty
                            last_seen_at:
                              type: string
                              format: date-time
                              description: When this row last appeared in a feed; stale rows are kept with in_stock=false
                            ingest_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Correlates rows from a single CSV import or feed pull
                            ingest_source:
                              type: string
                              enum: [csv, api, ftp, edi, manual]
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
              example:
                data:
                  products:
                    - &ProductExample
                      id: "3f8a1c2b-6d4e-4a7f-9b0c-1d2e3f4a5b6c"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      sku: "LT-DELL-5550"
                      name: "Dell Latitude 5550 i7/16GB/512GB"
                      description: "15.6in business laptop, 3yr ProSupport"
                      category: "Laptops"
                      manufacturer: "Dell"
                      pricing_mode: markup
                      default_cost_cents: 89900
                      default_sell_cents: 107880
                      markup_bps: 2000
                      currency_code: GBP
                      tax_code_id: "6c5d4e3f-2a1b-0c9d-8e7f-6a5b4c3d2e1f"
                      track_as_asset: true
                      qbo_push_pending: false
                      is_active: true
                      properties: {}
                      created_at: "2026-06-01T10:12:00Z"
                      updated_at: "2026-06-09T08:30:00Z"
                      created_by: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                  feeds:
                    - &FeedItemExample
                      id: "7e6d5c4b-3a2f-1e0d-9c8b-7a6f5e4d3c2b"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      supplier_id: "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e"
                      supplier_name: "Northgate Distribution Ltd"
                      supplier_sku: "WC123456"
                      name: "Dell Latitude 5550 i7 16GB 512GB W11P"
                      description: ""
                      manufacturer: "Dell"
                      category: "Notebooks"
                      cost_cents: 87650
                      currency_code: GBP
                      in_stock: true
                      stock_quantity: 14
                      last_seen_at: "2026-06-10T06:00:00Z"
                      ingest_id: "5a4b3c2d-1e0f-9a8b-7c6d-5e4f3a2b1c0d"
                      ingest_source: csv
                      created_at: "2026-05-20T09:00:00Z"
                      updated_at: "2026-06-10T06:00:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/contacts:
    get:
      x-stability: stable
      x-required-scope: psa-contacts:read
      summary: List contacts
      description: >
        Lists contacts for the caller's organisation with optional filters. Pagination is limit/offset (limit defaults to 50, max 200).

      operationId: listContacts
      tags: [Contacts]
      parameters:
        - name: client_id
          in: query
          description: Filter to contacts assigned to this client
          schema:
            type: string
            format: uuid
        - name: source
          in: query
          description: >
            Filter by external_source (manual | entra | email | cipp). The literal string "null" matches contacts with no source.

          schema:
            type: string
        - name: q
          in: query
          description: Free-text search across full_name, email and phone
          schema:
            type: string
        - name: is_portal_user
          in: query
          description: >
            true = only contacts with a linked active portal user; false = only contacts without one

          schema:
            type: boolean
        - name: unassigned
          in: query
          description: true = only contacts with no client assignment
          schema:
            type: boolean
        - name: limit
          in: query
          description: Page size (default 50, max 200)
          schema:
            type: integer
            default: 50
            maximum: 200
        - name: offset
          in: query
          description: Row offset (default 0)
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: Contact list with total row count
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          description: Contact row (models.Contact)
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            client_id:
                              type: string
                              format: uuid
                              nullable: true
                            full_name:
                              type: string
                            email:
                              type: string
                              nullable: true
                            phone:
                              type: string
                              nullable: true
                            title:
                              type: string
                              nullable: true
                            role:
                              type: string
                              nullable: true
                            is_primary:
                              type: boolean
                            external_source:
                              type: string
                              nullable: true
                              description: manual | entra | email | cipp
                            external_object_id:
                              type: string
                              nullable: true
                            properties:
                              type: object
                              description: Free-form JSONB tag/attribute blob
                            is_readonly:
                              type: boolean
                              description: true when managed by CIPP/Entra sync
                            is_billing:
                              type: boolean
                            is_vip:
                              type: boolean
                            windows_username:
                              type: string
                              nullable: true
                              description: AD/Windows account used for asset auto-linking
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
                      total:
                        type: integer
                        description: Total matching rows ignoring limit/offset
              example:
                data:
                  items:
                    - id: "8a4f2b1e-3c6d-4e89-9a01-2b3c4d5e6f70"
                      org_id: "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f"
                      client_id: "f1e2d3c4-b5a6-4789-8abc-def012345678"
                      full_name: "Sandra Whitfield"
                      email: "sandra@oakdental.example"
                      phone: "01423 556677"
                      title: "Practice Manager"
                      role: "Primary contact"
                      is_primary: true
                      external_source: null
                      external_object_id: null
                      properties: {}
                      is_readonly: false
                      is_billing: true
                      is_vip: false
                      windows_username: "sandra.whitfield"
                      created_at: "2026-03-14T09:30:00Z"
                      updated_at: "2026-06-02T16:45:12Z"
                  total: 1
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      x-stability: stable
      x-required-scope: psa-contacts:write
      summary: Create a contact
      description: >
        Creates a manually-entered contact. external_source is always forced to "manual" server-side — this endpoint cannot create Entra/CIPP contacts. full_name is required.

      operationId: createContact
      tags: [Contacts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [full_name]
              properties:
                client_id:
                  type: string
                  format: uuid
                  nullable: true
                full_name:
                  type: string
                email:
                  type: string
                  nullable: true
                phone:
                  type: string
                  nullable: true
                title:
                  type: string
                  nullable: true
                role:
                  type: string
                  nullable: true
                is_primary:
                  type: boolean
                  default: false
                windows_username:
                  type: string
                  nullable: true
                properties:
                  type: object
            example:
              client_id: "f1e2d3c4-b5a6-4789-8abc-def012345678"
              full_name: "Gareth Pemberton"
              email: "gareth@midlandfasteners.example"
              phone: "0114 273 8890"
              title: "Operations Director"
              is_primary: false
              windows_username: "gpemberton"
              properties:
                department: "Operations"
      responses:
        "201":
          description: Contact created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The created contact row (models.Contact)
              example:
                data:
                  id: "3b9e7d2c-1a4f-4d56-8e90-7f6a5b4c3d2e"
                  org_id: "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f"
                  client_id: "f1e2d3c4-b5a6-4789-8abc-def012345678"
                  full_name: "Gareth Pemberton"
                  email: "gareth@midlandfasteners.example"
                  phone: "0114 273 8890"
                  title: "Operations Director"
                  role: null
                  is_primary: false
                  external_source: "manual"
                  external_object_id: null
                  properties:
                    department: "Operations"
                  is_readonly: false
                  is_billing: false
                  is_vip: false
                  windows_username: "gpemberton"
                  created_at: "2026-06-10T11:02:44Z"
                  updated_at: "2026-06-10T11:02:44Z"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
  /psa/contacts/merge:
    post:
      x-stability: stable
      x-required-scope: psa-contacts:write
      summary: Merge two contacts
      description: >
        Merges the source contact into the target: re-points portal users and ticket requester references from source to target, shallow-merges properties (target wins), then hard-deletes the source row. Returns the merged target contact. 400 when source_id equals target_id; 404 when either contact is missing in the caller's organisation.

      operationId: mergeContacts
      tags: [Contacts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source_id, target_id]
              properties:
                source_id:
                  type: string
                  format: uuid
                  description: Contact to merge away (deleted on success)
                target_id:
                  type: string
                  format: uuid
                  description: Surviving contact
            example:
              source_id: "9d8c7b6a-5e4f-4321-a0b1-c2d3e4f5a6b7"
              target_id: "8a4f2b1e-3c6d-4e89-9a01-2b3c4d5e6f70"
      responses:
        "200":
          description: Merged target contact
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The surviving target contact row (models.Contact)
              example:
                data:
                  id: "8a4f2b1e-3c6d-4e89-9a01-2b3c4d5e6f70"
                  org_id: "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f"
                  client_id: "f1e2d3c4-b5a6-4789-8abc-def012345678"
                  full_name: "Sandra Whitfield"
                  email: "sandra@oakdental.example"
                  phone: "01423 556677"
                  title: "Practice Manager"
                  role: "Primary contact"
                  is_primary: true
                  external_source: null
                  external_object_id: null
                  properties:
                    department: "Reception"
                  is_readonly: false
                  is_billing: true
                  is_vip: false
                  windows_username: "sandra.whitfield"
                  created_at: "2026-03-14T09:30:00Z"
                  updated_at: "2026-06-10T14:21:09Z"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /psa/contacts/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-contacts:read
      summary: Get a contact by ID
      description: >
        Returns the contact plus its client-portal link projection (models.ContactDetail) in a single response.

      operationId: getContact
      tags: [Contacts]
      responses:
        "200":
          description: Contact detail with portal-link projection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      contact:
                        type: object
                        description: Contact row (models.Contact, same shape as the list items)
                      portal_link:
                        type: object
                        description: Linked client-portal user projection
                        properties:
                          linked:
                            type: boolean
                          user_id:
                            type: string
                            format: uuid
                            nullable: true
                          user_email:
                            type: string
                            nullable: true
                          user_active:
                            type: boolean
                            nullable: true
                          last_login_at:
                            type: string
                            format: date-time
                            nullable: true
              example:
                data:
                  contact:
                    id: "8a4f2b1e-3c6d-4e89-9a01-2b3c4d5e6f70"
                    org_id: "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f"
                    client_id: "f1e2d3c4-b5a6-4789-8abc-def012345678"
                    full_name: "Sandra Whitfield"
                    email: "sandra@oakdental.example"
                    phone: "01423 556677"
                    title: "Practice Manager"
                    role: "Primary contact"
                    is_primary: true
                    external_source: null
                    external_object_id: null
                    properties: {}
                    is_readonly: false
                    is_billing: true
                    is_vip: false
                    windows_username: "sandra.whitfield"
                    created_at: "2026-03-14T09:30:00Z"
                    updated_at: "2026-06-02T16:45:12Z"
                  portal_link:
                    linked: true
                    user_id: "1f2e3d4c-5b6a-4798-8190-a1b2c3d4e5f6"
                    user_email: "sandra@oakdental.example"
                    user_active: true
                    last_login_at: "2026-06-09T08:12:30Z"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      x-stability: stable
      x-required-scope: psa-contacts:write
      summary: Partially update a contact
      description: >
        Partial update — omitted fields are left untouched (explicit JSON null is treated the same as omitted; columns cannot currently be nulled out individually). external_source / external_object_id are read-only and silently ignored if sent. For CIPP-managed (read-only) contacts, identity fields (full_name, email) already populated by CIPP cannot be changed — the server returns 403 with code READONLY_FIELD; supplementary fields (phone, title, role, client_id, is_primary) remain editable.

      operationId: updateContact
      tags: [Contacts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id:
                  type: string
                  format: uuid
                  nullable: true
                full_name:
                  type: string
                  nullable: true
                email:
                  type: string
                  nullable: true
                phone:
                  type: string
                  nullable: true
                title:
                  type: string
                  nullable: true
                role:
                  type: string
                  nullable: true
                is_primary:
                  type: boolean
                  nullable: true
                is_billing:
                  type: boolean
                  nullable: true
                is_vip:
                  type: boolean
                  nullable: true
                windows_username:
                  type: string
                  nullable: true
                properties:
                  type: object
            example:
              phone: "01423 556699"
              title: "Senior Practice Manager"
              is_vip: true
      responses:
        "200":
          description: Updated contact
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The updated contact row (models.Contact)
              example:
                data:
                  id: "8a4f2b1e-3c6d-4e89-9a01-2b3c4d5e6f70"
                  org_id: "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f"
                  client_id: "f1e2d3c4-b5a6-4789-8abc-def012345678"
                  full_name: "Sandra Whitfield"
                  email: "sandra@oakdental.example"
                  phone: "01423 556699"
                  title: "Senior Practice Manager"
                  role: "Primary contact"
                  is_primary: true
                  external_source: null
                  external_object_id: null
                  properties: {}
                  is_readonly: false
                  is_billing: true
                  is_vip: true
                  windows_username: "sandra.whitfield"
                  created_at: "2026-03-14T09:30:00Z"
                  updated_at: "2026-06-10T15:40:01Z"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            Permission/scope denied, or attempt to change a CIPP-managed identity field (code READONLY_FIELD)

          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  message: "full_name is managed by CIPP"
                  code: "READONLY_FIELD"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      x-stability: stable
      x-required-scope: psa-contacts:write
      summary: Delete a contact
      description: >
        Deletes a contact. CIPP-managed (read-only) contacts cannot be deleted — the server returns 403 with code READONLY_CONTACT.

      operationId: deleteContact
      tags: [Contacts]
      responses:
        "204":
          description: Contact deleted
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >
            Permission/scope denied, or the contact is CIPP-managed (code READONLY_CONTACT)

          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  message: "contact is managed by CIPP and cannot be deleted"
                  code: "READONLY_CONTACT"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /psa/contacts/{id}/mailbox-stats:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-contacts:read
      summary: Get mailbox storage stats for a contact
      description: >
        Returns Microsoft 365 mailbox storage stats for a CIPP-synced contact. When no stats row exists the response is 200 with has_stats=false and no further fields (this is not an error).

      operationId: getContactMailboxStats
      tags: [Contacts]
      responses:
        "200":
          description: Mailbox stats (or has_stats=false when none synced)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      has_stats:
                        type: boolean
                        description: false when no stats row exists; remaining fields absent
                      license_name:
                        type: string
                        nullable: true
                      mailbox_size_gb:
                        type: number
                        nullable: true
                      mailbox_quota_gb:
                        type: number
                        nullable: true
                      usage_percent:
                        type: number
                        nullable: true
                      last_synced_at:
                        type: string
                        format: date-time
                        nullable: true
              example:
                data:
                  has_stats: true
                  license_name: "Microsoft 365 Business Premium"
                  mailbox_size_gb: 38.4
                  mailbox_quota_gb: 50
                  usage_percent: 76.8
                  last_synced_at: "2026-06-10T03:15:00Z"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
  /psa/contacts/{id}/portal-link:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-contacts:write
      summary: Link a contact to a client-portal account
      description: >
        Creates (or links) a client-portal user for this contact using the given email. 409 when the email already belongs to another contact in this organisation or to any user in a different organisation. send_invite enqueues a portal invite email.

      operationId: linkContactPortalUser
      tags: [Contacts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                send_invite:
                  type: boolean
                  default: false
            example:
              email: "sandra@oakdental.example"
              send_invite: true
      responses:
        "200":
          description: Portal user linked
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [linked]
              example:
                data:
                  status: "linked"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: >
            Email already in use by another contact or organisation (code PORTAL_LINK_CONFLICT)

          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                error:
                  message: "email already in use by another contact or organisation"
                  code: "PORTAL_LINK_CONFLICT"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      x-stability: stable
      x-required-scope: psa-contacts:write
      summary: Unlink a contact's client-portal account
      description: Removes the portal-user link for this contact.
      operationId: unlinkContactPortalUser
      tags: [Contacts]
      responses:
        "204":
          description: Portal user unlinked
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /psa/contacts/{id}/tickets:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-contacts:read
      summary: List tickets raised by a contact
      description: >
        Lists PSA tickets whose requester is this contact. Pagination is limit/offset (limit defaults to 25). Items are full PSA ticket objects; only the core fields are documented here.

      operationId: listContactTickets
      tags: [Contacts]
      parameters:
        - name: limit
          in: query
          description: Page size (default 25)
          schema:
            type: integer
            default: 25
        - name: offset
          in: query
          description: Row offset (default 0)
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: Ticket list with total row count
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          description: >
                            PSA ticket (models.Ticket). Core fields listed; the full object carries additional SLA, triage and AI-assist fields.

                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            ticket_number:
                              type: integer
                              format: int64
                              description: Per-org sequential number (e.g. 1042 in INC-1042)
                            client_id:
                              type: string
                              format: uuid
                              nullable: true
                            site_id:
                              type: string
                              format: uuid
                              nullable: true
                            requester_contact_id:
                              type: string
                              format: uuid
                              nullable: true
                            assigned_to:
                              type: string
                              format: uuid
                              nullable: true
                            subject:
                              type: string
                            description:
                              type: string
                            status:
                              type: string
                            impact:
                              type: integer
                            urgency:
                              type: integer
                            priority_level:
                              type: string
                            source_type:
                              type: string
                            type:
                              type: string
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
                      total:
                        type: integer
                        description: Total matching rows ignoring limit/offset
              example:
                data:
                  items:
                    - id: "4e5f6a7b-8c9d-4012-a345-b678c901d234"
                      org_id: "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f"
                      ticket_number: 1042
                      client_id: "f1e2d3c4-b5a6-4789-8abc-def012345678"
                      site_id: null
                      requester_contact_id: "8a4f2b1e-3c6d-4e89-9a01-2b3c4d5e6f70"
                      assigned_to: "1a2b3c4d-5e6f-4708-9a1b-2c3d4e5f6a7b"
                      subject: "Reception PC will not print to the Konica"
                      description: "Printer shows offline since this morning."
                      status: "open"
                      impact: 3
                      urgency: 3
                      priority_level: "medium"
                      source_type: "email"
                      type: "incident"
                      created_at: "2026-06-09T08:55:00Z"
                      updated_at: "2026-06-09T10:14:22Z"
                  total: 1
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /psa/csat/aggregate:
    get:
      x-stability: beta
      x-required-scope: psa-tickets:read
      summary: CSAT aggregate metrics
      description: |
        Sent/responded counts, response rate, average rating and trend for
        the dashboard widget and Reports -> CSAT page header. Defaults to
        the last 30 days (`since` overrides; the window always ends now).
        `trend` compares the requested window's average rating against the
        immediately-prior equal-length window: a delta above 0.1 is
        "up"/"down", otherwise "flat"; either window having zero responses
        forces "flat". `response_rate` is rounded to 2 decimals and the
        rating averages to 1 decimal. MSP staff only; requires a PSA or
        Bundle tier.
      operationId: getCsatAggregate
      tags: [CSAT]
      parameters:
        - name: client_id
          in: query
          description: Restrict the aggregate to one client; omit for an org-wide rollup
          schema: {type: string, format: uuid}
        - name: since
          in: query
          description: "RFC3339 window start (must be in the past); default: 30 days ago"
          schema: {type: string, format: date-time}
      responses:
        "200":
          description: Aggregate metrics for the requested window
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      sent:
                        type: integer
                        description: Surveys sent in the window
                      responded:
                        type: integer
                        description: Surveys answered in the window
                      response_rate:
                        type: number
                        format: double
                        description: responded / sent, rounded to 2 decimals (0 when nothing sent)
                      average_rating:
                        type: number
                        format: double
                        description: Mean rating in the window, rounded to 1 decimal
                      trend:
                        type: string
                        enum: [up, down, flat]
                      prior_average:
                        type: number
                        format: double
                        description: Mean rating in the immediately-prior equal-length window
                      since:
                        type: string
                        format: date-time
                      until:
                        type: string
                        format: date-time
              example:
                data:
                  sent: 42
                  responded: 17
                  response_rate: 0.4
                  average_rating: 4.6
                  trend: up
                  prior_average: 4.3
                  since: "2026-05-11T00:00:00Z"
                  until: "2026-06-10T14:21:09Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
  /psa/csat/responses:
    get:
      x-stability: beta
      x-required-scope: psa-tickets:read
      summary: List recent CSAT responses
      description: |
        Recent answered surveys for the Reports -> CSAT "Recent feedback"
        table, newest first. Cursor pagination (NOT page/per_page): pass
        the returned `next_cursor` (an RFC3339Nano `responded_at`
        timestamp) as `before` to fetch the next page; `next_cursor` is
        omitted on the final page. Only answered surveys are returned —
        pending sends never appear here. Comments are excerpted to 200
        characters server-side. MSP staff only; requires a PSA or Bundle
        tier.
      operationId: listCsatResponses
      tags: [CSAT]
      parameters:
        - name: limit
          in: query
          description: Rows to return (max 100)
          schema: {type: integer, default: 25, maximum: 100}
        - name: before
          in: query
          description: Cursor — only rows with responded_at strictly before this RFC3339 timestamp
          schema: {type: string, format: date-time}
      responses:
        "200":
          description: Page of answered surveys, responded_at descending
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            ticket_id:
                              type: string
                              format: uuid
                            ticket_subject:
                              type: string
                            client_name:
                              type: string
                            contact_name:
                              type: string
                            rating:
                              type: integer
                              nullable: true
                              minimum: 1
                              maximum: 5
                            comment:
                              type: string
                              nullable: true
                              description: Excerpt (first 200 characters); omitted when empty
                            responded_at:
                              type: string
                              format: date-time
                              nullable: true
                      next_cursor:
                        type: string
                        nullable: true
                        description: RFC3339Nano responded_at cursor for the next page; omitted on the last page
              example:
                data:
                  items:
                    - id: "7be4a1c2-90cf-4f5e-8a3d-2f1f7c6e9b10"
                      ticket_id: "0d9f9d6e-3a44-4d6b-9a1f-7c2c2f6f1a01"
                      ticket_subject: "Printer offline in accounts"
                      client_name: "Greendale Wealth Management"
                      contact_name: "Sarah Pemberton"
                      rating: 5
                      comment: "Sorted in ten minutes, brilliant."
                      responded_at: "2026-06-10T14:21:09Z"
                  next_cursor: "2026-06-10T14:21:09.412345678Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
  /psa/email/activity:
    get:
      x-stability: internal
      x-required-scope: psa-email:read
      summary: List email activity
      description: |
        Read-only timeline of inbound and outbound email events backing
        the Email Gateway Activity tab. `limit`/`offset` paging with a
        `{items, total}` payload; `total` is the full filtered count.
        `outcome` may be repeated (`?outcome=ticket_created&outcome=triage`).
        Invalid `since`/`until` timestamps are silently ignored, as are
        `direction` values other than `inbound`/`outbound`.
      operationId: listEmailActivity
      tags: [Email Gateway]
      parameters:
        - name: q
          in: query
          description: Free-text filter
          schema: {type: string}
        - name: direction
          in: query
          schema:
            type: string
            enum: [inbound, outbound]
        - name: outcome
          in: query
          description: Outcome filter; repeatable. Inbound and outbound use disjoint sets.
          explode: true
          schema:
            type: array
            items:
              type: string
              enum: [ticket_created, thread_comment, triage, blocked, submitted, delivered, failed, bounced]
        - name: since
          in: query
          description: Only events at or after this RFC3339 timestamp
          schema: {type: string, format: date-time}
        - name: until
          in: query
          description: Only events before this RFC3339 timestamp
          schema: {type: string, format: date-time}
        - name: limit
          in: query
          description: Maximum rows to return
          schema: {type: integer, default: 50}
        - name: offset
          in: query
          description: Rows to skip
          schema: {type: integer, default: 0}
      responses:
        "200":
          description: Activity page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &EmailActivityEntry
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            occurred_at:
                              type: string
                              format: date-time
                            direction:
                              type: string
                              enum: [inbound, outbound]
                            outcome:
                              type: string
                              enum: [ticket_created, thread_comment, triage, blocked, submitted, delivered, failed, bounced]
                            sender:
                              type: string
                              nullable: true
                            recipient:
                              type: string
                              nullable: true
                            subject:
                              type: string
                              nullable: true
                            message_id:
                              type: string
                              nullable: true
                            mail_log_id:
                              type: string
                              nullable: true
                              description: Haraka mail-log id for outbound entries; drives the detail endpoint's gateway lookup
                            ticket_id:
                              type: string
                              format: uuid
                              nullable: true
                            comment_id:
                              type: string
                              format: uuid
                              nullable: true
                            size_bytes:
                              type: integer
                              nullable: true
                            diagnostic:
                              type: string
                              nullable: true
                            raw_headers:
                              description: Raw JSON headers blob; omitted when not captured
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                      total:
                        type: integer
                        description: Full filtered row count
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/activity/summary:
    get:
      x-stability: internal
      x-required-scope: psa-email:read
      summary: Email activity summary
      description: |
        Counts of email activity grouped by outcome since the given
        timestamp (default: last 24 hours). An unparseable `since` falls
        back to the default window.
      operationId: getEmailActivitySummary
      tags: [Email Gateway]
      parameters:
        - name: since
          in: query
          description: RFC3339 window start (default now minus 24h)
          schema: {type: string, format: date-time}
      responses:
        "200":
          description: Outcome counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      since:
                        type: string
                        format: date-time
                      counts:
                        type: object
                        description: Map of outcome -> count
                        additionalProperties:
                          type: integer
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/activity/{id}:
    get:
      x-stability: internal
      x-required-scope: psa-email:read
      summary: Get email activity detail
      description: |
        One activity row plus, for outbound entries with a `mail_log_id`,
        a best-effort fetch of the mail gateway's raw delivery details
        (queue timestamps, delivery attempts, MTA response codes). A
        gateway outage never fails the request — `gateway_error` carries
        the upstream failure message instead.
      operationId: getEmailActivityDetail
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Activity detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      entry: *EmailActivityEntry
                      gateway:
                        description: Verbatim Haraka mail-log payload (raw JSON); omitted for inbound rows or when the upstream call failed
                        nullable: true
                      gateway_error:
                        type: string
                        description: Non-fatal upstream lookup failure; omitted on success
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/blocklist:
    get:
      x-stability: internal
      x-required-scope: psa-email:read
      summary: List blocklist entries
      description: |
        All inbound sender blocklist entries for the organisation. No
        pagination — returns the full list.
      operationId: listEmailBlocklist
      tags: [Email Gateway]
      responses:
        "200":
          description: Blocklist entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &BlocklistEntry
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        pattern_type:
                          type: string
                          enum: [address, domain]
                        pattern:
                          type: string
                        notes:
                          type: string
                          nullable: true
                        created_by:
                          type: string
                          format: uuid
                          nullable: true
                        created_at:
                          type: string
                          format: date-time
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Add blocklist entry
      description: |
        Blocks an inbound sender. `pattern_type` is optional — when empty
        it is inferred from the pattern (`address` for values containing
        `@`, `domain` otherwise). Duplicate entries return 409
        ALREADY_BLOCKED.
      operationId: addEmailBlocklistEntry
      tags: [Email Gateway]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pattern]
              properties:
                pattern_type:
                  type: string
                  enum: [address, domain]
                  description: Optional — inferred from the pattern when empty
                pattern:
                  type: string
                  description: Sender address or bare domain to block
                notes:
                  type: string
                  nullable: true
      responses:
        "201":
          description: Entry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *BlocklistEntry
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Sender already on the blocklist (code ALREADY_BLOCKED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/blocklist/{id}:
    delete:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Remove blocklist entry
      description: Deletes a blocklist entry. Idempotent — deleting a missing id still returns 204.
      operationId: removeEmailBlocklistEntry
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "204":
          description: Entry removed
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/domains:
    get:
      x-stability: internal
      x-required-scope: psa-email:read
      summary: Get outbound domain config (Haraka)
      description: |
        The tenant's single Haraka outbound email configuration. There is
        at most ONE config row per organisation (this is the legacy
        single-domain surface; multi-domain lives under /psa/email/hyvor).
        When no domain has been provisioned the response is
        `{"data":{"configured":false}}` with HTTP 200, not a 404.
        Includes the DNS records the tenant must publish when available.
      operationId: getEmailDomainConfig
      tags: [Email Gateway]
      responses:
        "200":
          description: Domain config, or configured=false when not set up
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - type: object
                        description: Provisioned config with the DNS records to publish
                        properties:
                          id: &EmailDomainConfigId
                            type: string
                            format: uuid
                          haraka_tenant_id: &EmailDomainConfigTenantId
                            type: string
                            description: Deterministic per-org mail-gateway tenant id (org-<8hex>)
                          outbound_domain: &EmailDomainConfigDomain
                            type: string
                            nullable: true
                          outbound_domain_status: &EmailDomainConfigStatus
                            type: string
                            description: Lifecycle status (pending, dns_pending, active, ...)
                          inbound_mailboxes_enabled: &EmailDomainConfigInbound
                            type: boolean
                          last_status_at: &EmailDomainConfigLastStatusAt
                            type: string
                            format: date-time
                            nullable: true
                          created_at: &EmailDomainConfigCreatedAt
                            type: string
                            format: date-time
                          dns_records: &DNSRecords
                            type: object
                            nullable: true
                            description: The three TXT records the tenant must publish
                            properties:
                              dkim: &DNSRecord
                                type: object
                                properties:
                                  type: {type: string}
                                  name: {type: string}
                                  value: {type: string}
                              spf: *DNSRecord
                              dmarc: *DNSRecord
                      - type: object
                        description: Returned when no email config exists yet
                        properties:
                          configured:
                            type: boolean
                            enum: [false]
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Provision outbound domain (Haraka)
      description: |
        Provisions the tenant's outbound sending domain on the Haraka
        mail gateway and returns the DNS records to publish. Requires a
        verified, unexpired, unconsumed ownership challenge for the exact
        domain (see /psa/email/domains/challenge) — otherwise 403
        OWNERSHIP_NOT_VERIFIED. The `from_address` domain must match
        `outbound_domain`. A domain claimed by another tenant returns 409
        DOMAIN_ALREADY_CLAIMED; a persistent gateway conflict returns 409
        DOMAIN_CONFLICT; 503 GATEWAY_DISABLED when the gateway is not
        configured on this cell.
      operationId: provisionEmailDomain
      tags: [Email Gateway]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [outbound_domain, from_address]
              properties:
                outbound_domain:
                  type: string
                  description: Sending domain (normalised to lowercase, protocol/trailing-dot stripped)
                from_name:
                  type: string
                  description: Display name for the From header
                from_address:
                  type: string
                  description: From address; its domain must equal outbound_domain
      responses:
        "201":
          description: Domain provisioned; status is dns_pending until verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: *EmailDomainConfigId
                      haraka_tenant_id: *EmailDomainConfigTenantId
                      outbound_domain: *EmailDomainConfigDomain
                      outbound_domain_status: *EmailDomainConfigStatus
                      inbound_mailboxes_enabled: *EmailDomainConfigInbound
                      last_status_at: *EmailDomainConfigLastStatusAt
                      created_at: *EmailDomainConfigCreatedAt
                      dns_records: *DNSRecords
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Domain claimed by another tenant (DOMAIN_ALREADY_CLAIMED) or stale gateway registration (DOMAIN_CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: Mail gateway provisioning failed (code GATEWAY_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Mail gateway not configured on this cell (code GATEWAY_DISABLED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/email/domains/challenge:
    post:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Issue domain ownership challenge
      description: |
        Issues (or refreshes) a 24-hour DNS ownership challenge for a
        domain. The caller must publish the returned token as a TXT record
        at `_opsmerge-verify.<domain>` and then call
        /psa/email/domains/challenge/verify. Provisioning the domain
        requires a verified challenge.
      operationId: issueEmailDomainChallenge
      tags: [Email Gateway]
      requestBody:
        required: true
        content:
          application/json:
            schema: &ChallengeRequest
              type: object
              required: [outbound_domain]
              properties:
                outbound_domain:
                  type: string
                  description: Domain to prove ownership of (normalised to lowercase)
      responses:
        "200":
          description: TXT record to publish
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      domain:
                        type: string
                      record_name:
                        type: string
                        description: Fully-qualified TXT name (_opsmerge-verify.<domain>)
                      record_value:
                        type: string
                        description: Token to publish (omv_<32hex>)
                      expires_at:
                        type: string
                        format: date-time
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/domains/challenge/verify:
    post:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Verify domain ownership challenge
      description: |
        Resolves the `_opsmerge-verify.<domain>` TXT record and marks the
        outstanding challenge verified when the token matches. ALWAYS
        returns 200 with a body describing the outcome — `verified` plus a
        human-readable `message` and the raw `observed` TXT values on
        failure. 404 CHALLENGE_NOT_FOUND when no challenge was issued for
        this domain.
      operationId: verifyEmailDomainChallenge
      tags: [Email Gateway]
      requestBody:
        required: true
        content:
          application/json:
            schema: *ChallengeRequest
      responses:
        "200":
          description: Verification outcome (success or actionable failure detail)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      domain:
                        type: string
                      record_name:
                        type: string
                      verified:
                        type: boolean
                      expires_at:
                        type: string
                        format: date-time
                      message:
                        type: string
                        description: Failure reason; omitted on success
                      observed:
                        type: array
                        description: Raw TXT values seen at record_name; omitted when the lookup returned nothing
                        items:
                          type: string
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/domains/{id}:
    put:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Update outbound domain (Haraka)
      description: |
        Updates `from_name` and/or `from_address` on the mail gateway. A
        non-empty `from_address` must match the provisioned domain.
        Returns the (unchanged) stored config — the from fields live on
        the gateway, not in the PSA row.
      operationId: updateEmailDomain
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                from_name:
                  type: string
                  nullable: true
                from_address:
                  type: string
                  nullable: true
      responses:
        "200":
          description: Stored config
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &EmailDomainConfig
                    type: object
                    properties:
                      id: *EmailDomainConfigId
                      haraka_tenant_id: *EmailDomainConfigTenantId
                      outbound_domain: *EmailDomainConfigDomain
                      outbound_domain_status: *EmailDomainConfigStatus
                      inbound_mailboxes_enabled: *EmailDomainConfigInbound
                      last_status_at: *EmailDomainConfigLastStatusAt
                      created_at: *EmailDomainConfigCreatedAt
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: Mail gateway update failed (code GATEWAY_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Mail gateway not configured on this cell (code GATEWAY_DISABLED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    delete:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Delete outbound domain (Haraka)
      description: |
        Removes the domain from the mail gateway (best-effort) and deletes
        the tenant's email config row.
      operationId: deleteEmailDomain
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "204":
          description: Config deleted
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/domains/{id}/verify:
    post:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Verify outbound domain DNS (Haraka)
      description: |
        Asks the mail gateway to re-check the domain's DKIM/SPF/DMARC and
        additionally runs the PSA's own independent DNS cross-check
        (`local`). The persisted status is the AND of the two — if the
        gateway reports active but the local check fails, the domain stays
        dns_pending. 409 DOMAIN_DESYNC when the gateway no longer knows
        the domain (remove and re-add to resync).
      operationId: verifyEmailDomain
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Combined verification result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        description: Final persisted status (AND of gateway + local checks)
                      dkim_verified:
                        type: boolean
                        description: Gateway's own DKIM result
                      spf_verified:
                        type: boolean
                      dmarc_verified:
                        type: boolean
                      local:
                        type: object
                        nullable: true
                        description: PSA's independent DNS cross-check; null when no expected records are stored
                        properties:
                          dkim: &DNSRecordCheck
                            type: object
                            properties:
                              status:
                                type: string
                                enum: [ok, missing, mismatch, error]
                              message:
                                type: string
                                description: Omitted when the check passed
                              name:
                                type: string
                              expected:
                                type: string
                                description: Omitted when no expected value applies
                              observed:
                                type: array
                                description: Raw TXT values seen; omitted when none
                                items:
                                  type: string
                          spf: *DNSRecordCheck
                          dmarc: *DNSRecordCheck
                          all_pass:
                            type: boolean
                            description: True iff DKIM and SPF both match (DMARC is informational)
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Gateway no longer has this domain registered (code DOMAIN_DESYNC)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: Gateway verify call failed (code GATEWAY_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Mail gateway not configured on this cell (code GATEWAY_DISABLED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/email/hyvor/domains:
    get:
      x-stability: internal
      x-required-scope: psa-email:read
      summary: List outbound domains (Hyvor)
      description: |
        All of the organisation's outbound sending domains on the Hyvor
        relay, each enriched with live status + DKIM record from one relay
        call. If the relay is unreachable the stored status is returned
        (no live DKIM); a domain missing from the relay reports status
        `unknown` with a resync hint. The whole /hyvor subtree is only
        mounted when the Hyvor gateway is enabled on the cell — otherwise
        these paths 404. No pagination.
      operationId: listHyvorDomains
      tags: [Email Gateway]
      responses:
        "200":
          description: Domain list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      domains:
                        type: array
                        items: &HyvorDomain
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            domain:
                              type: string
                            status:
                              type: string
                              description: Live relay status when reachable (e.g. pending, active); `unknown` on relay/local drift
                            is_primary:
                              type: boolean
                              description: The org's default sender domain
                            client_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Optional white-label client linkage; omitted when unset
                            from_local_part:
                              type: string
                              description: Sender alias local part (e.g. support, helpdesk)
                            dkim:
                              type: object
                              nullable: true
                              description: DNS record the tenant must publish; omitted until the relay returns one
                              properties:
                                host: {type: string}
                                type: {type: string}
                                value: {type: string}
                            dkim_error_message:
                              type: string
                              description: Relay-reported DKIM problem; omitted when none
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Email gateway not configured (code GATEWAY_DISABLED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    post:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Add outbound domain (Hyvor)
      description: |
        Registers the domain on the shared Hyvor relay and binds it to the
        organisation. The first domain an org adds becomes its primary
        (default sender). Re-adding a domain the org already owns is
        idempotent and returns 200 with the existing record instead of
        201. A domain owned by another tenant returns 409
        DOMAIN_ALREADY_CLAIMED. As a side effect the org's Haraka inbound
        side is provisioned (best-effort) so replies round-trip into
        tickets.
      operationId: addHyvorDomain
      tags: [Email Gateway]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain:
                  type: string
                  description: Sending domain (normalised to lowercase)
                client_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Optional white-label client linkage
      responses:
        "200":
          description: Domain already bound to this org (idempotent re-add)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *HyvorDomain
        "201":
          description: Domain registered and bound
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *HyvorDomain
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Domain already registered with another tenant (code DOMAIN_ALREADY_CLAIMED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: Relay add failed (code GATEWAY_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Email gateway not configured (code GATEWAY_DISABLED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/email/hyvor/domains/{id}:
    patch:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Update outbound domain (Hyvor)
      description: |
        Updates the per-domain sender alias (`from_local_part`) so the MSP
        can send from helpdesk@, tickets@, etc. The local part must be
        1-64 characters of lowercase letters, digits, dot, underscore,
        plus or hyphen. Currently the only updatable field — a body
        without it is rejected with 400.
      operationId: updateHyvorDomain
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                from_local_part:
                  type: string
                  nullable: true
                  description: New sender alias local part
      responses:
        "200":
          description: Updated domain
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *HyvorDomain
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Remove outbound domain (Hyvor)
      description: |
        Removes the domain from the relay (best-effort) and unbinds it. If
        it was the primary and other domains remain, the oldest remaining
        domain is promoted to primary.
      operationId: deleteHyvorDomain
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "204":
          description: Domain removed
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/hyvor/domains/{id}/primary:
    post:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Set primary outbound domain (Hyvor)
      description: |
        Makes the domain the org's default sender. The previous primary is
        demoted in the same transaction.
      operationId: setHyvorDomainPrimary
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Updated domain (is_primary=true)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *HyvorDomain
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/hyvor/domains/{id}/verify:
    post:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Verify outbound domain DNS (Hyvor)
      description: |
        Asks the relay to re-check the domain's DNS and reports the
        updated status. Idempotent — re-verifying an already-settled
        domain returns the live status rather than erroring. 409
        DOMAIN_DESYNC when the relay no longer knows the domain.
      operationId: verifyHyvorDomain
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Domain with refreshed status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *HyvorDomain
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Relay no longer has this domain registered (code DOMAIN_DESYNC)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: Relay verify call failed (code GATEWAY_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Email gateway not configured (code GATEWAY_DISABLED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/email/hyvor/sends:
    get:
      x-stability: internal
      x-required-scope: psa-email:read
      summary: List sent messages (Hyvor delivery reporting)
      description: |
        Paginated list of messages sent on the tenant's behalf via the
        Hyvor relay, newest first, fed by webhook delivery telemetry.
        `limit`/`offset` paging. PAGINATION CAVEAT: `total` is the count
        returned IN THIS PAGE, not a grand total — the FE grows the list
        page by page. `status` and `q` are filtered in-process over a
        500-row scan window, so matches older than the newest 500 sends
        are not returned.
      operationId: listHyvorSends
      tags: [Email Gateway]
      parameters:
        - name: status
          in: query
          description: Exact current_status filter (delivered, deferred, bounced, ...)
          schema: {type: string}
        - name: q
          in: query
          description: Case-insensitive substring match on to_address or subject
          schema: {type: string}
        - name: limit
          in: query
          description: Page size (clamped to 500)
          schema: {type: integer, default: 50}
        - name: offset
          in: query
          description: Rows to skip
          schema: {type: integer, default: 0}
      responses:
        "200":
          description: Sends page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &EmailSend
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            hyvor_send_id:
                              type: integer
                              format: int64
                              nullable: true
                            hyvor_send_uuid:
                              type: string
                              nullable: true
                            source_type:
                              type: string
                              nullable: true
                            source_id:
                              type: string
                              format: uuid
                              nullable: true
                            from_address:
                              type: string
                              nullable: true
                            to_address:
                              type: string
                              nullable: true
                            subject:
                              type: string
                              nullable: true
                            current_status:
                              type: string
                              description: Latest delivery status (queued, delivered, deferred, bounced, ...)
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
                      total:
                        type: integer
                        description: Count of items in THIS page (not a grand total)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/hyvor/sends/{id}:
    get:
      x-stability: internal
      x-required-scope: psa-email:read
      summary: Get sent message detail (Hyvor delivery reporting)
      description: |
        One send plus its ordered delivery-event timeline, each event
        carrying the verbatim SMTP transcript so the full delivery path
        can be rendered.
      operationId: getHyvorSend
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Send with event timeline
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      send: *EmailSend
                      events:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            email_send_id:
                              type: string
                              format: uuid
                            event_type:
                              type: string
                              nullable: true
                            recipient_address:
                              type: string
                              nullable: true
                            attempt_number:
                              type: integer
                              nullable: true
                            status:
                              type: string
                              nullable: true
                            responded_mx_host:
                              type: string
                              nullable: true
                            smtp_code:
                              type: integer
                              nullable: true
                            smtp_enhanced_code:
                              type: string
                              nullable: true
                            smtp_message:
                              type: string
                              nullable: true
                            is_suppressed:
                              type: boolean
                              nullable: true
                            transcript:
                              description: Verbatim SMTP transcript (raw JSON); omitted when not captured
                              nullable: true
                            occurred_at:
                              type: string
                              format: date-time
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/hyvor/webhook:
    post:
      x-stability: internal
      operationId: receiveHyvorRelayWebhook
      summary: Hyvor Relay delivery webhook
      description: |
        Receives outbound-delivery events from the self-hosted Hyvor Relay
        instance: `send.recipient.accepted|deferred|bounced|complained|suppressed|failed`,
        `domain.status.changed`, and `suppression.created|deleted`. One shared
        endpoint for all tenants — Hyvor signs with a single platform secret
        (`cfg.HyvorGateway.WebhookSecret`), so verification happens before any
        org is known: HMAC-SHA256 over the raw body, constant-time compared
        against the hex-encoded `X-Signature` header (no `sha256=` prefix).
        Events are correlated back to the owning org by the send UUID recorded
        at send time. Body capped at 5 MB. Route is only mounted when the Hyvor
        gateway is configured with both API key and webhook secret
        (`cfg.HyvorGateway.WebhookEnabled()`); otherwise it 404s.
      tags: ["Webhooks (Inbound)"]
      security: []
      parameters:
        - name: X-Signature
          in: header
          required: true
          description: Hex-encoded HMAC-SHA256 of the raw body, keyed with the platform Hyvor webhook secret.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event, payload]
              properties:
                event:
                  type: string
                  description: Hyvor event type (e.g. `send.recipient.bounced`, `domain.status.changed`).
                payload:
                  type: object
                  description: Event-specific payload; `send.recipient.*` carries `send`, `recipient` and `attempt` (full SMTP transcript) objects.
      responses:
        "200":
          description: Event stored (or acknowledged — unknown/unparseable-but-signed events are 200 so Hyvor does not retry).
        "401":
          description: Missing or invalid `X-Signature` (plain text).
        "413":
          description: Body exceeded the 5 MB cap.
  /psa/email/inbound-mailboxes:
    get:
      x-stability: internal
      x-required-scope: psa-email:read
      summary: List inbound mailboxes
      description: |
        All inbound mailbox routing rules for the organisation: per-local-
        part default client assignment, contact auto-creation, and the
        catch-all flag. No pagination.
      operationId: listInboundMailboxes
      tags: [Email Gateway]
      responses:
        "200":
          description: Mailbox list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &InboundMailbox
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        local_part:
                          type: string
                          description: Local part of the inbound address (e.g. support)
                        default_client_id:
                          type: string
                          format: uuid
                          nullable: true
                        auto_create_contacts:
                          type: boolean
                        is_catch_all:
                          type: boolean
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Create inbound mailbox
      description: |
        Adds a per-local-part inbound routing rule. `auto_create_contacts`
        defaults to true and `is_catch_all` to false when omitted.
      operationId: createInboundMailbox
      tags: [Email Gateway]
      requestBody:
        required: true
        content:
          application/json:
            schema: &InboundMailboxRequest
              type: object
              required: [local_part]
              properties:
                local_part:
                  type: string
                default_client_id:
                  type: string
                  format: uuid
                  nullable: true
                auto_create_contacts:
                  type: boolean
                  nullable: true
                  description: Defaults to true when omitted
                is_catch_all:
                  type: boolean
                  nullable: true
                  description: Defaults to false when omitted
      responses:
        "201":
          description: Mailbox created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *InboundMailbox
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/inbound-mailboxes/{id}:
    put:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Update inbound mailbox
      description: |
        Full replace of the mailbox's routing rule. Omitted
        `auto_create_contacts` resets to true; omitted `is_catch_all`
        resets to false.
      operationId: updateInboundMailbox
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema: *InboundMailboxRequest
      responses:
        "200":
          description: Updated mailbox
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *InboundMailbox
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-email:write
      summary: Delete inbound mailbox
      description: Deletes the routing rule. Idempotent — a missing id still returns 204.
      operationId: deleteInboundMailbox
      tags: [Email Gateway]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "204":
          description: Mailbox deleted
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/email/webhook:
    post:
      x-stability: internal
      operationId: receiveHarakaEmailWebhook
      summary: Haraka inbound email webhook
      description: |
        Receives `inbound_email` and `bounce` events from the Haraka SMTP
        gateway. Authentication is a two-phase per-tenant HMAC (D-09): the
        handler peek-decodes `tenantId` from the untrusted body, loads that
        tenant's encrypted webhook secret with an admin-role query, decrypts it
        (AES-256-GCM), computes HMAC-SHA256 over the exact raw request body and
        constant-time compares it against the `X-Webhook-Signature` header
        (format `sha256=<hex digest>`). All auth failures return an
        indistinguishable plain-text 401 to prevent tenant enumeration (D-04).
        Body is capped at 30 MB (`413` beyond that). Unknown event types are
        acknowledged with 200 so Haraka does not retry.
      tags: ["Webhooks (Inbound)"]
      security: []
      parameters:
        - name: X-Webhook-Signature
          in: header
          required: true
          description: "`sha256=` + hex HMAC-SHA256 of the raw body, keyed with the per-tenant webhook secret."
          schema:
            type: string
            example: "sha256=8b1a9953c4611296a827abf8c47804d7e6c49c6b"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenantId, event, data]
              properties:
                tenantId:
                  type: string
                  description: Haraka tenant identifier used to resolve the per-tenant HMAC secret.
                event:
                  type: string
                  description: Event type. Unknown values are acknowledged and ignored.
                  enum: [inbound_email, bounce]
                data:
                  type: object
                  description: Event payload (parsed email for `inbound_email`; delivery-status detail for `bounce`).
      responses:
        "200":
          description: Event accepted (or acknowledged as unprocessable so Haraka does not retry).
        "401":
          description: Missing/invalid signature, unknown tenant, or unparseable peek body (plain text, deliberately indistinguishable).
        "413":
          description: Body exceeded the 30 MB cap.
        "500":
          description: Secret decryption or downstream processing failure.
  /psa/integrations/cipp:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Get CIPP connection status
      description: |
        Sanitised view — never returns the client secret. Returns 200
        with has_connection=false when not configured. A sync stuck in
        'running' for over 15 minutes is surfaced (and persisted back)
        as 'failed'.
      operationId: getCippStatus
      tags: [CIPP]
      responses:
        "200":
          description: Connection status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      has_connection: {type: boolean}
                      connected: {type: boolean}
                      cipp_url: {type: string}
                      tenant_id: {type: string}
                      client_id: {type: string}
                      app_id: {type: string}
                      last_sync_at: {type: string, format: date-time, nullable: true}
                      last_sync_status: {type: string}
                      last_sync_error: {type: string}
                      last_sync_count: {type: integer, nullable: true}
                      sync_failures:
                        type: array
                        description: Per-tenant failure records from the last sync
                        items: {type: object, additionalProperties: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Disconnect CIPP
      description: Deletes the connection and ALL tenant mappings. Imported contacts are kept.
      operationId: disconnectCipp
      tags: [CIPP]
      responses:
        "204":
          description: Disconnected
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/cipp/connect:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Connect CIPP
      description: |
        Persists the MSP's CIPP instance URL (https only) and Azure AD
        client-credentials app details. app_id defaults to client_id when
        omitted. The secret is encrypted at rest. No live validation call
        is made here — bad credentials surface on the first sync.
      operationId: connectCipp
      tags: [CIPP]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cipp_url, tenant_id, client_id, client_secret]
              properties:
                cipp_url: {type: string, description: https URL of the CIPP instance}
                tenant_id: {type: string}
                client_id: {type: string}
                client_secret: {type: string}
                app_id: {type: string, description: Defaults to client_id}
      responses:
        "200":
          description: Connected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [connected]}
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/cipp/sync:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Trigger a CIPP contact sync
      description: |
        Enqueues the CIPP contact-sync background job and immediately
        marks the connection 'running'. Rate-limited to one trigger per
        minute (429 with Retry-After: 60).
      operationId: syncCippContacts
      tags: [CIPP]
      responses:
        "202":
          description: Sync enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [running]}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: CIPP not configured (NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          description: Sync ran within the last minute (RATE_LIMITED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/cipp/tenants:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List CIPP tenant mappings
      description: All CIPP tenant mappings with joined client names. No pagination.
      operationId: listCippTenantMappings
      tags: [CIPP]
      responses:
        "200":
          description: Mappings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            cipp_tenant_id: {type: string}
                            cipp_domain: {type: string}
                            cipp_name: {type: string}
                            client_id: {type: string, format: uuid, nullable: true}
                            client_name: {type: string, nullable: true}
                            auto_created: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/cipp/tenants/auto-create:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Auto-create clients for unmapped CIPP tenants
      description: |
        Creates a local client (plus a default "Head Office" site) for
        every unmapped CIPP tenant and links it with auto_created=true.
        Per-tenant insert failures are skipped, hence created <= total.
      operationId: autoCreateCippClients
      tags: [CIPP]
      responses:
        "200":
          description: Result counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created: {type: integer}
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/cipp/tenants/{cippTenantId}/map:
    put:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Map a CIPP tenant to a client
      description: |
        Sets the client_id on one tenant mapping. An empty client_id
        clears the mapping.
      operationId: mapCippTenant
      tags: [CIPP]
      parameters:
        - name: cippTenantId
          in: path
          required: true
          schema: {type: string}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id: {type: string, format: uuid, description: Empty string clears the mapping}
      responses:
        "200":
          description: Mapped
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [mapped]}
        "400":
          description: Invalid client_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Tenant mapping not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/cipp/tenants/{cippTenantId}/sync:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Sync one CIPP tenant now
      description: |
        Synchronous single-tenant contact sync (no rate limit) — used to
        retry tenants from the sync-failures list. Only licensed users
        are imported. Clears the tenant from sync_failures on success.
      operationId: syncCippTenant
      tags: [CIPP]
      parameters:
        - name: cippTenantId
          in: path
          required: true
          schema: {type: string}
      responses:
        "200":
          description: Sync result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      domain: {type: string}
                      synced: {type: integer}
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: CIPP not connected, or mapping not found / unmapped
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: CIPP token or ListUsers call failed (CIPP_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/cipp/unmapped-count:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Count unmapped CIPP tenants
      description: Lightweight dashboard count; returns 0 when CIPP is not connected.
      operationId: getCippUnmappedCount
      tags: [CIPP]
      responses:
        "200":
          description: Count
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      count: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/entra:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Get Entra connection status
      description: |
        Sanitised view — never returns secrets or tokens. Returns 200
        with connected=false when not configured.
      operationId: getEntraStatus
      tags: [Entra]
      responses:
        "200":
          description: Connection status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      connected: {type: boolean}
                      tenant_id: {type: string}
                      client_id: {type: string}
                      has_tokens: {type: boolean}
                      expires_at: {type: string, format: date-time, nullable: true}
                      last_sync_at: {type: string, format: date-time, nullable: true}
                      last_sync_status: {type: string}
                      last_sync_error: {type: string}
                      last_sync_count: {type: integer, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Disconnect Entra
      description: |
        Best-effort token revocation at Microsoft, then deletes the local
        connection row. Contacts imported from Entra are kept. Idempotent
        — 204 even when already disconnected.
      operationId: disconnectEntra
      tags: [Entra]
      responses:
        "204":
          description: Disconnected
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/entra/authorize:
    get:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Start the Entra OAuth flow
      description: |
        Generates PKCE verifier + session-bound state and 302-redirects
        the browser to Microsoft's authorize endpoint. Browser-flow
        endpoint — not useful for API-key callers.
      operationId: authorizeEntra
      tags: [Entra]
      responses:
        "302":
          description: Redirect to login.microsoftonline.com
          headers:
            Location:
              schema: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Entra connection not configured (NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/entra/callback:
    get:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Entra OAuth callback
      description: |
        Completes the OAuth flow: validates the single-use session-bound
        state, exchanges the code for tokens, persists them encrypted,
        and 302-redirects to /settings/entra?success=1. Browser-flow
        endpoint.
      operationId: entraOAuthCallback
      tags: [Entra]
      parameters:
        - name: state
          in: query
          required: true
          schema: {type: string}
        - name: code
          in: query
          required: true
          schema: {type: string}
      responses:
        "302":
          description: Redirect to the Entra settings page
          headers:
            Location:
              schema: {type: string}
        "400":
          description: Missing/expired/cross-session state (INVALID_STATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: Token exchange with Microsoft failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/entra/connect:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Save Entra app credentials
      description: |
        Persists tenant_id, client_id, and the encrypted client_secret.
        Swapping the app resets any stored tokens, so the admin must
        re-consent via the returned authorize_url.
      operationId: connectEntra
      tags: [Entra]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_id, client_id, client_secret]
              properties:
                tenant_id: {type: string, description: UUID or *.onmicrosoft.com domain, maxLength: 256}
                client_id: {type: string, maxLength: 64}
                client_secret: {type: string, maxLength: 512}
      responses:
        "200":
          description: Saved — browser should follow authorize_url next
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      authorize_url: {type: string}
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/entra/sync:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Trigger an Entra contact sync
      description: |
        Enqueues the Entra contact-sync background job. Rate limited to
        one trigger per 5 minutes (429 with Retry-After: 300).
      operationId: syncEntraContacts
      tags: [Entra]
      responses:
        "202":
          description: Sync job enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      job_id: {type: integer, format: int64}
                      enqueued_for: {type: string, format: uuid, description: Organisation id}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Entra connection not configured (NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          description: Synced within the last 5 minutes (RATE_LIMITED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/exertis:
    get:
      x-stability: internal
      x-required-scope: psa-products:read
      summary: Get Exertis connection
      description: |
        Sanitised connection row — the identity token is never returned
        (has_identity_token signals storage). Returns 200 with
        `data: null` when no connection exists.
      operationId: getExertisConnection
      tags: [Distributors]
      responses:
        "200":
          description: Connection, or null
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &ExertisConnection
                    type: object
                    nullable: true
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      account_number: {type: string}
                      has_identity_token: {type: boolean}
                      is_sandbox: {type: boolean}
                      has_bearer_token: {type: boolean}
                      bearer_token_expires_at: {type: string, format: date-time, nullable: true}
                      last_sync_at: {type: string, format: date-time, nullable: true}
                      last_sync_status: {type: string}
                      last_sync_error: {type: string}
                      revoked_at: {type: string, format: date-time, nullable: true}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      x-required-scope: psa-products:write
      summary: Create or update the Exertis connection
      description: |
        Upserts credentials. identity_token is only required on first
        connect or rotation — omit it to leave the stored ciphertext
        unchanged.
      operationId: upsertExertisConnection
      tags: [Distributors]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_number]
              properties:
                account_number: {type: string}
                identity_token: {type: string, nullable: true}
                is_sandbox: {type: boolean}
      responses:
        "200":
          description: Saved connection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ExertisConnection
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: psa-products:write
      summary: Disconnect Exertis
      description: Wipes credentials and the cached token. 204 even when nothing existed.
      operationId: deleteExertisConnection
      tags: [Distributors]
      responses:
        "204":
          description: Disconnected
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/exertis/catalog:
    post:
      x-stability: internal
      x-required-scope: psa-products:read
      summary: Pull the Exertis catalogue
      description: |
        Live catalogue pull from Exertis eHUB. POST despite being a read
        — the optional SKU filter rides in the body. Cursor-paged via
        next_cursor (page_size default 50, max 500); an empty body
        triggers an unfiltered initial pull.
      operationId: exertisCataloguePull
      tags: [Distributors]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                sku_list:
                  type: array
                  items: {type: string}
                page_size: {type: integer, default: 50, maximum: 500}
                next_cursor: {type: string}
      responses:
        "200":
          description: Catalogue page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            sku: {type: string}
                            ean: {type: string}
                            name: {type: string}
                            condition: {type: string}
                            price:
                              type: integer
                              format: int64
                              description: Raw integer price as returned by Exertis (pence-vs-pounds unconfirmed upstream)
                            stock: {type: integer}
                            last_stock_update: {type: string}
                            status: {type: string}
                      next_cursor: {type: string}
                      total_count: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "412":
          description: Exertis not configured for this tenant (NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Upstream Exertis call failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/exertis/test:
    post:
      x-stability: internal
      x-required-scope: psa-products:write
      summary: Test the Exertis connection
      description: |
        Exchanges the stored credentials for a bearer token. The token is
        never returned; failures land in error_message with success=false
        (still HTTP 200).
      operationId: testExertisConnection
      tags: [Distributors]
      responses:
        "200":
          description: Test result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success: {type: boolean}
                      expires_in: {type: integer}
                      error_message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/freeagent:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Get FreeAgent connection status
      description: |
        Sanitised connection view — never carries decrypted tokens. Always
        succeeds: a missing connection row returns `connected: false`.
        `configured` reflects whether the platform operator supplied OAuth
        credentials for the connection's environment. `blocked_by` names
        another accounts provider (e.g. `quickbooks_online`) holding the
        one-provider-per-tenant slot; `sync_available` is false on builds
        where the sync workers are not wired (POST /sync would 503).
      operationId: getFreeAgentStatus
      tags: [FreeAgent]
      responses:
        "200":
          description: Connection status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      connected:
                        type: boolean
                      configured:
                        type: boolean
                      environment:
                        type: string
                        enum: [sandbox, production]
                      company_url:
                        type: string
                        description: Connected FreeAgent company URL (omitted until cached)
                      has_tokens:
                        type: boolean
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                        description: Access-token expiry (omitted when not connected)
                      last_sync_at:
                        type: string
                        format: date-time
                        nullable: true
                      last_sync_status:
                        type: string
                      last_sync_error:
                        type: string
                      revoked_at:
                        type: string
                        format: date-time
                        nullable: true
                      deposit_bank_account_uri:
                        type: string
                      last_payment_sync_at:
                        type: string
                        format: date-time
                        nullable: true
                      last_payment_sync_status:
                        type: string
                      last_payment_sync_error:
                        type: string
                      blocked_by:
                        type: string
                        description: Other accounts provider already connected (omitted when none)
                      sync_available:
                        type: boolean
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Disconnect FreeAgent
      description: |
        Deletes the connection row, wipes `external_*` sync metadata on
        clients/services/invoices/tax_rates linked to FreeAgent, deletes
        the pending-import queue, and invalidates any in-flight OAuth
        flows. FreeAgent has no token-revoke endpoint, so disconnect is
        local-only. Idempotent: returns 204 even when no connection
        exists. Transaction rows keep their external ids (immutable
        ledger / audit trail).
      operationId: disconnectFreeAgent
      tags: [FreeAgent]
      responses:
        "204":
          description: Disconnected (or nothing to disconnect)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/freeagent/authorize:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Start FreeAgent OAuth flow
      description: |
        Generates a CSRF `state` bound to the current session and returns
        the FreeAgent authorize URL for the SPA to navigate to (the API
        cannot 302 because the Bearer header would be lost on a top-level
        browser redirect). No PKCE — FreeAgent does not support it.
        Re-authorizing an existing connection is allowed (token rotation,
        switching company).
      operationId: authorizeFreeAgent
      tags: [FreeAgent]
      responses:
        "200":
          description: Authorize URL to navigate the browser to
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      authorize_url:
                        type: string
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Another accounts provider is already connected (code PROVIDER_CONFLICT)
          headers:
            X-Accounts-Provider-In-Use:
              description: The provider currently holding the slot
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: FreeAgent credentials not configured by the platform operator (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/freeagent/bank-accounts:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List FreeAgent bank accounts
      description: |
        Live fetch of the connected company's bank accounts for the
        deposit-account dropdown (payments are recorded against the
        chosen account). Not cached or paginated.
      operationId: listFreeAgentBankAccounts
      tags: [FreeAgent]
      responses:
        "200":
          description: Bank accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        url:
                          type: string
                          description: FreeAgent bank account URI (stored as deposit_bank_account_uri)
                        name:
                          type: string
                        bank_name:
                          type: string
                        type:
                          type: string
                        currency:
                          type: string
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No FreeAgent connection (code NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Connection revoked, reconnect required (code RECONNECT_REQUIRED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: FreeAgent API call failed (code UPSTREAM_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: FreeAgent integration not configured (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/freeagent/callback:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Complete FreeAgent OAuth flow
      description: |
        Called by the SPA bounce page after FreeAgent redirects the
        browser back: it reposts the `code` and `state` query parameters
        so the request carries the Bearer token. Consumes the state row
        atomically (single use, session-bound), exchanges the code for
        tokens, and persists them encrypted. Refuses stale flows: a newer
        completed flow (STALE_FLOW), a concurrent other-provider connect
        (PROVIDER_CONFLICT), or a disconnect during the flow
        (OAUTH_INVALIDATED) all return 409.
      operationId: completeFreeAgentCallback
      tags: [FreeAgent]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, state]
              properties:
                code:
                  type: string
                  description: OAuth authorization code from the redirect
                state:
                  type: string
                  description: State value issued by POST /authorize
      responses:
        "200":
          description: Connected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [connected]
        "400":
          description: Missing fields, or invalid/expired/foreign-session state (codes INVALID_BODY, INVALID_REQUEST, INVALID_STATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Flow refused (codes PROVIDER_CONFLICT, STALE_FLOW, OAUTH_INVALIDATED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Token exchange with FreeAgent failed (code UPSTREAM_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Credentials missing for the state row's environment (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/freeagent/create-from-local:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Push a local client to FreeAgent
      description: |
        Synchronously creates the OpsMerge client as a new FreeAgent
        contact and links it. Only clients are supported (`entity_type`
        must be `contact` or omitted) — FreeAgent has no service/product
        entity. Idempotent: retrying once linked returns 200 with
        `status: already_mapped`.
      operationId: createFreeAgentFromLocal
      tags: [FreeAgent]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [local_id]
              properties:
                entity_type:
                  type: string
                  enum: [contact]
                  description: Optional; only 'contact' (or omitted) is accepted
                local_id:
                  type: string
                  format: uuid
                  description: OpsMerge client id
      responses:
        "200":
          description: Created (or already linked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [created, already_mapped]
                      external_id:
                        type: string
                        description: FreeAgent contact URI
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Client not found, or no FreeAgent connection (codes NOT_FOUND, NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Connection revoked, reconnect required (code RECONNECT_REQUIRED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: FreeAgent API call failed (code UPSTREAM_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: FreeAgent integration not configured (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/freeagent/income-categories:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List FreeAgent income categories
      description: |
        Live fetch of the connected company's income categories for the
        default-income-category dropdown. Not cached or paginated.
      operationId: listFreeAgentIncomeCategories
      tags: [FreeAgent]
      responses:
        "200":
          description: Income categories
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        url:
                          type: string
                          description: FreeAgent category URI (stored as default_income_category_uri)
                        description:
                          type: string
                        nominal_code:
                          type: string
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No FreeAgent connection (code NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Connection revoked, reconnect required (code RECONNECT_REQUIRED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: FreeAgent API call failed (code UPSTREAM_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: FreeAgent integration not configured (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/freeagent/lookup-local:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Autocomplete local map targets (FreeAgent)
      description: |
        Autocomplete source for the "Map to existing..." dropdown.
        Searches clients (`contact`) or services (`category`) by name
        substring; prefix matches sort first. Fixed cap of 50 rows, no
        paging. `external_system` is included so the UI can distinguish
        rows genuinely linked to FreeAgent from rows carrying a stale
        other-provider id (which the map guard will overwrite).
      operationId: lookupFreeAgentLocalEntities
      tags: [FreeAgent]
      parameters:
        - name: entity_type
          in: query
          required: true
          schema:
            type: string
            enum: [contact, category]
        - name: q
          in: query
          description: Name substring (truncated to 100 chars)
          schema: {type: string}
      responses:
        "200":
          description: Matching local entities
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        name:
                          type: string
                        external_id:
                          type: string
                          description: Current external link, omitted when unlinked
                        external_system:
                          type: string
                          description: Provider owning external_id, omitted when unlinked
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/freeagent/pending-imports:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List FreeAgent pending imports
      description: |
        Rows from the import-review queue, most recently seen first.
        Limit-only paging (no offset, no total). The auto-matcher's
        advisory suggestion rides along as `suggested_local_id` /
        `suggested_local_name` / `suggested_score`; nothing is created or
        linked without an explicit decision.
      operationId: listFreeAgentPendingImports
      tags: [FreeAgent]
      parameters:
        - name: entity_type
          in: query
          description: Filter by entity type; omit for all
          schema:
            type: string
            enum: [contact, category]
        - name: decision
          in: query
          description: Filter by decision; omit for all
          schema:
            type: string
            enum: [pending, create, map, skip, always_skip]
        - name: limit
          in: query
          description: "Max rows, 1-1000; out-of-range values fall back to the default"
          schema: {type: integer, default: 200, maximum: 1000}
      responses:
        "200":
          description: Pending import rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        entity_type:
                          type: string
                          enum: [contact, category]
                        external_id:
                          type: string
                          description: FreeAgent resource URI
                        display_name:
                          type: string
                        decision:
                          type: string
                          enum: [pending, create, map, skip, always_skip]
                        mapped_to_local_id:
                          type: string
                          format: uuid
                          nullable: true
                        mapped_local_name:
                          type: string
                        suggested_local_id:
                          type: string
                          format: uuid
                          nullable: true
                        suggested_local_name:
                          type: string
                        suggested_score:
                          type: number
                          format: float
                          nullable: true
                        payload:
                          description: Raw FreeAgent entity JSON as last pulled
                        first_seen_at:
                          type: string
                          format: date-time
                        last_seen_at:
                          type: string
                          format: date-time
                        decided_at:
                          type: string
                          format: date-time
                          nullable: true
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/freeagent/pending-imports/{id}:
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Decide a FreeAgent pending import
      description: |
        Records the decision and applies it inline in the same
        transaction: `create` upserts a local client/service from the
        cached payload, `map` stamps the FreeAgent id on the chosen
        local row (`mapped_to_local_id` required). Mapping onto a row
        already linked to a different FreeAgent record returns 409
        ALREADY_MAPPED. Idempotent.
      operationId: updateFreeAgentPendingImport
      tags: [FreeAgent]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision:
                  type: string
                  enum: [pending, create, map, skip, always_skip]
                mapped_to_local_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Required when decision is 'map'
      responses:
        "200":
          description: Decision recorded and applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [ok]
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Pending import or map target not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Map target already linked to a different FreeAgent record (code ALREADY_MAPPED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/freeagent/preferences:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Get FreeAgent preferences
      description: |
        Per-tenant auto-create flags, the deposit bank account, and the
        default income category. 404 when no connection exists.
      operationId: getFreeAgentPreferences
      tags: [FreeAgent]
      responses:
        "200":
          description: Preferences
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      auto_create_contacts:
                        type: boolean
                      auto_create_categories:
                        type: boolean
                      deposit_bank_account_uri:
                        type: string
                        description: Omitted when unset
                      default_income_category_uri:
                        type: string
                        description: Omitted when unset
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No FreeAgent connection (code NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Update FreeAgent preferences
      description: |
        Partial update. Omitted fields keep their current value. For the
        URI fields an empty string clears the value; non-empty values
        must be well-formed FreeAgent resource URIs for the connection's
        environment (400 otherwise).
      operationId: updateFreeAgentPreferences
      tags: [FreeAgent]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                auto_create_contacts:
                  type: boolean
                  nullable: true
                auto_create_categories:
                  type: boolean
                  nullable: true
                deposit_bank_account_uri:
                  type: string
                  nullable: true
                  description: FreeAgent bank account URI; empty string clears
                default_income_category_uri:
                  type: string
                  nullable: true
                  description: FreeAgent category URI; empty string clears
      responses:
        "204":
          description: Preferences saved
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No FreeAgent connection (code NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/freeagent/sync:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Trigger a manual FreeAgent sync
      description: |
        Enqueues a per-org sync job; the sync itself runs asynchronously
        (poll the status endpoint for the outcome). Cooldown of 5 minutes
        since the last sync — inside the window the call returns 429 with
        `Retry-After: 300`.
      operationId: triggerFreeAgentSync
      tags: [FreeAgent]
      responses:
        "202":
          description: Sync job enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      job_id:
                        type: integer
                        format: int64
                      enqueued_for:
                        type: string
                        format: uuid
                        description: Organisation id the sync was enqueued for
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No FreeAgent connection (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          description: Synced within the last 5 minutes; see Retry-After (code RATE_LIMITED)
          headers:
            Retry-After:
              description: Seconds until another sync is allowed
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Sync workers not wired in this build, or credentials missing for the connection's environment (codes SYNC_UNAVAILABLE, NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/freeagent/sync-history:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List FreeAgent sync history
      description: |
        Rows from the FreeAgent sync log, newest first. Limit-only paging
        (no offset, no total). Only invoice pushes and payment polling are
        logged here — the pull-side contact/category sync uses the
        import-review queue instead. Filter values outside the allowlists
        are silently ignored (treated as no filter), not rejected.
      operationId: listFreeAgentSyncHistory
      tags: [FreeAgent]
      parameters:
        - name: limit
          in: query
          description: "Max rows, 1-200; out-of-range values fall back to the default"
          schema: {type: integer, default: 50, maximum: 200}
        - name: resource_type
          in: query
          schema:
            type: string
            enum: [invoices, transactions]
        - name: status
          in: query
          schema:
            type: string
            enum: [success, error, skipped]
        - name: direction
          in: query
          schema:
            type: string
            enum: [inbound, outbound, noop]
        - name: action
          in: query
          schema:
            type: string
            enum: [create_remote, update_remote, void_remote, payment_in, error]
        - name: since
          in: query
          description: Inclusive lower bound on synced_at (RFC 3339)
          schema: {type: string, format: date-time}
        - name: until
          in: query
          description: Inclusive upper bound on synced_at (RFC 3339)
          schema: {type: string, format: date-time}
      responses:
        "200":
          description: Sync log rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        resource_type:
                          type: string
                        external_id:
                          type: string
                          description: Omitted when empty
                        action:
                          type: string
                        direction:
                          type: string
                        status:
                          type: string
                        error_message:
                          type: string
                          description: Omitted when empty
                        synced_at:
                          type: string
                          format: date-time
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/freeagent/unmapped-local:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List local entities unmapped to FreeAgent
      description: |
        Local clients (`contact`) or services (`category`) with no
        FreeAgent link, name-ascending. Rows are capped at 200 but
        `total_count` carries the pre-cap total of the filtered set.
        Note the response shape differs from the QBO twin: items are
        wrapped with a total rather than returned as a bare array.
      operationId: listFreeAgentUnmappedLocal
      tags: [FreeAgent]
      parameters:
        - name: entity_type
          in: query
          required: true
          schema:
            type: string
            enum: [contact, category]
        - name: q
          in: query
          description: Name substring (truncated to 100 chars)
          schema: {type: string}
      responses:
        "200":
          description: Unmapped local entities
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            name:
                              type: string
                      total_count:
                        type: integer
                        format: int64
                        description: Pre-cap total of matching rows
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/ingram:
    get:
      x-stability: internal
      x-required-scope: psa-products:read
      summary: Get Ingram Micro connection
      description: |
        Sanitised connection row — client_secret and access_token surface
        only as has_* booleans. Returns 200 with `data: null` when no
        connection exists.
      operationId: getIngramConnection
      tags: [Distributors]
      responses:
        "200":
          description: Connection, or null
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &IngramConnection
                    type: object
                    nullable: true
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      client_id: {type: string}
                      has_client_secret: {type: boolean}
                      customer_number: {type: string}
                      country_code: {type: string}
                      environment: {type: string}
                      has_access_token: {type: boolean}
                      access_token_expires_at: {type: string, format: date-time, nullable: true}
                      last_sync_at: {type: string, format: date-time, nullable: true}
                      last_sync_status: {type: string}
                      last_sync_error: {type: string}
                      revoked_at: {type: string, format: date-time, nullable: true}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      x-required-scope: psa-products:write
      summary: Create or update the Ingram Micro connection
      description: |
        Upserts credentials for the Xvantage Reseller API. client_secret
        is only required on first connect or rotation; any update clears
        the cached access token so the next call refreshes it.
      operationId: upsertIngramConnection
      tags: [Distributors]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, customer_number, environment]
              properties:
                client_id: {type: string}
                client_secret: {type: string, nullable: true}
                customer_number: {type: string}
                country_code: {type: string, nullable: true}
                environment: {type: string}
      responses:
        "200":
          description: Saved connection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *IngramConnection
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: psa-products:write
      summary: Disconnect Ingram Micro
      description: Wipes credentials and the cached token. 204 even when nothing existed.
      operationId: deleteIngramConnection
      tags: [Distributors]
      responses:
        "204":
          description: Disconnected
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/ingram/catalog:
    get:
      x-stability: internal
      x-required-scope: psa-products:read
      summary: Search the Ingram catalogue
      description: |
        Live proxy of the Xvantage catalogue search for the quote
        builder. The response is Ingram's own camelCase envelope, passed
        through. Paging is upstream-style pageNumber/pageSize (not the
        platform page/per_page convention).
      operationId: searchIngramCatalog
      tags: [Distributors]
      parameters:
        - name: q
          in: query
          description: Keyword
          schema: {type: string}
        - name: page
          in: query
          schema: {type: integer}
        - name: page_size
          in: query
          schema: {type: integer}
      responses:
        "200":
          description: Catalogue page (upstream camelCase keys)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      recordsFound: {type: integer}
                      pageSize: {type: integer}
                      pageNumber: {type: integer}
                      nextPage: {type: string}
                      previousPage: {type: string}
                      catalog:
                        type: array
                        items:
                          type: object
                          properties:
                            ingramPartNumber: {type: string}
                            vendorPartNumber: {type: string}
                            vendorName: {type: string}
                            description: {type: string}
                            category: {type: string}
                            subCategory: {type: string}
                            productType: {type: string}
                            productAuthorized: {type: boolean}
                            endUserRequired: {type: boolean}
                            hasDiscounts: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "412":
          description: Ingram not configured for this tenant (NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Upstream Ingram call failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/ingram/pa:
    post:
      x-stability: internal
      x-required-scope: psa-products:read
      summary: Ingram price and availability lookup
      description: |
        Live P&A lookup for the quote builder. POST despite being a read
        — the part-number list rides in the body. Response rows use
        Ingram's camelCase keys.
      operationId: ingramPriceAndAvailability
      tags: [Distributors]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ingram_part_numbers]
              properties:
                ingram_part_numbers:
                  type: array
                  items: {type: string}
      responses:
        "200":
          description: P&A rows (upstream camelCase keys)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    nullable: true
                    items:
                      type: object
                      properties:
                        ingramPartNumber: {type: string}
                        vendorPartNumber: {type: string}
                        vendorName: {type: string}
                        description: {type: string}
                        availability:
                          type: object
                          nullable: true
                          properties:
                            available: {type: boolean}
                            totalAvailability: {type: integer}
                            warehouses:
                              type: array
                              items:
                                type: object
                                properties:
                                  location: {type: string}
                                  quantityAvailable: {type: integer}
                                  quantityBackordered: {type: integer}
                                  backOrderDate: {type: string}
                        pricing:
                          type: object
                          nullable: true
                          properties:
                            retailPrice: {type: number}
                            customerPrice: {type: number}
                            currencyCode: {type: string}
        "400":
          description: ingram_part_numbers missing or body invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "412":
          description: Ingram not configured for this tenant (NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Upstream Ingram call failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/ingram/test:
    post:
      x-stability: internal
      x-required-scope: psa-products:write
      summary: Test the Ingram connection
      description: |
        Exchanges the stored credentials for a token; the token itself is
        never returned. Failures land in error_message with success=false
        (still HTTP 200).
      operationId: testIngramConnection
      tags: [Distributors]
      responses:
        "200":
          description: Test result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success: {type: boolean}
                      expires_in: {type: integer}
                      token_type: {type: string}
                      error_message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/pax8:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Get Pax8 connection status
      description: |
        Sanitised view — never carries decrypted secrets. An absent row
        is reported as connected=false with 200 (not 404). `configured`
        reflects whether the platform operator has enabled Pax8 on this
        cell.
      operationId: getPax8Status
      tags: [Pax8]
      responses:
        "200":
          description: Connection status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &Pax8ConnectionView
                    type: object
                    properties:
                      connected: {type: boolean}
                      configured: {type: boolean}
                      pax8_partner_id: {type: string}
                      pax8_partner_name: {type: string}
                      pax8_credential_label: {type: string}
                      has_token: {type: boolean}
                      expires_at: {type: string, format: date-time, nullable: true}
                      last_sync_at: {type: string, format: date-time, nullable: true}
                      last_sync_status: {type: string}
                      last_sync_error: {type: string}
                      last_successful_sync_at: {type: string, format: date-time, nullable: true}
                      consecutive_sync_failures: {type: integer}
                      last_token_refresh_at: {type: string, format: date-time, nullable: true}
                      last_token_refresh_error: {type: string}
                      revoked_at: {type: string, format: date-time, nullable: true}
                      webhook_callback_url: {type: string}
                      auto_link_companies: {type: boolean}
                      auto_link_subscriptions: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Disconnect Pax8
      description: Clean break — deletes the connection row. Idempotent (204 either way).
      operationId: disconnectPax8
      tags: [Pax8]
      responses:
        "204":
          description: Disconnected
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/pax8/connect:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Connect Pax8
      description: |
        BYO credentials (created in the MSP's own Pax8 marketplace under
        Integrations -> API credentials). The pair is smoke-tested
        against Pax8 BEFORE persist — bad credentials return 401
        PAX8_REJECTED immediately. On success the sanitised status view
        is returned and a per-tenant webhook path token + secret are
        minted.
      operationId: connectPax8
      tags: [Pax8]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, client_secret]
              properties:
                client_id: {type: string, minLength: 8, maxLength: 256}
                client_secret: {type: string, minLength: 8, maxLength: 1024}
      responses:
        "200":
          description: Connected — sanitised status view
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Pax8ConnectionView
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          description: Pax8 rejected the credentials (PAX8_REJECTED) — or missing auth
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: Could not reach Pax8
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Pax8 integration disabled by the platform operator (NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/pax8/credentials:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Rotate Pax8 credentials
      description: |
        Same smoke-test-then-persist flow as connect but requires an
        existing connection (404 NOT_CONNECTED otherwise). The existing
        webhook path token + secret are preserved so registered Pax8
        webhook subscriptions keep delivering.
      operationId: rotatePax8Credentials
      tags: [Pax8]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, client_secret]
              properties:
                client_id: {type: string, minLength: 8, maxLength: 256}
                client_secret: {type: string, minLength: 8, maxLength: 1024}
      responses:
        "200":
          description: Rotated — sanitised status view
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Pax8ConnectionView
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          description: Pax8 rejected the credentials (PAX8_REJECTED) — or missing auth
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No Pax8 connection exists yet (NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Could not reach Pax8
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Pax8 integration disabled by the platform operator
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/pax8/linked:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List Pax8-linked clients
      description: Clients with a pax8_company_id, newest linked first. No pagination.
      operationId: listPax8Linked
      tags: [Pax8]
      responses:
        "200":
          description: Linked clients (null when none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    nullable: true
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        name: {type: string}
                        pax8_company_id: {type: string}
                        pax8_synced_at: {type: string, format: date-time, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/pax8/lookup-local:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Typeahead lookup of local clients
      description: |
        Supports the "map" decision typeahead — clients matching the
        search term, max 25. An empty q returns an empty array.
      operationId: lookupPax8LocalClients
      tags: [Pax8]
      parameters:
        - name: q
          in: query
          description: Name substring (case-insensitive)
          schema: {type: string}
      responses:
        "200":
          description: Matching clients (null when no matches)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    nullable: true
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        name: {type: string}
                        pax8_company_id: {type: string, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/pax8/pending-imports:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List the Pax8 pending-imports queue
      description: |
        Pending company/subscription imports awaiting an operator
        decision, newest-seen first, hard-capped at 500 rows (no paging).
        Rows are enriched with suggested_local_name to avoid N+1 lookups.
      operationId: listPax8PendingImports
      tags: [Pax8]
      parameters:
        - name: entity_type
          in: query
          schema:
            type: string
            enum: [company, subscription]
            default: company
        - name: decision
          in: query
          description: Decision state to filter by
          schema: {type: string, default: pending}
      responses:
        "200":
          description: Pending rows (null when none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    nullable: true
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        entity_type: {type: string, enum: [company, subscription]}
                        external_id: {type: string}
                        display_name: {type: string}
                        payload:
                          type: object
                          additionalProperties: true
                          description: Staged Pax8 payload for the row
                        decision: {type: string}
                        mapped_to_local_id: {type: string, format: uuid, nullable: true}
                        suggested_local_id: {type: string, format: uuid, nullable: true}
                        suggested_score: {type: number, nullable: true}
                        suggested_local_name: {type: string}
                        first_seen_at: {type: string, format: date-time}
                        last_seen_at: {type: string, format: date-time}
                        decided_at: {type: string, format: date-time, nullable: true}
        "400":
          description: Invalid entity_type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/pax8/pending-imports/{id}:
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Decide a Pax8 pending import
      description: |
        Applies an operator decision. For companies: create mints a new
        client; map links an existing one (mapped_to_local_id =
        clients.id). For subscriptions: create mints a contract line on
        target_contract_id; map binds an existing line
        (mapped_to_local_id = contract_lines.id). skip re-prompts next
        sync; always_skip is sticky. prorate_on_change seeds the new
        line's proration behaviour (default true).
      operationId: decidePax8PendingImport
      tags: [Pax8]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision:
                  type: string
                  enum: [create, map, skip, always_skip]
                mapped_to_local_id: {type: string, format: uuid, nullable: true}
                target_contract_id: {type: string, format: uuid, nullable: true}
                prorate_on_change: {type: boolean, nullable: true}
      responses:
        "200":
          description: Decision recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      decision: {type: string}
                      mapped_to_local_id: {type: string, format: uuid, nullable: true}
                      target_contract_id: {type: string, format: uuid, nullable: true}
        "400":
          description: Bad decision, missing mapped_to_local_id/target_contract_id, or CLIENT_MISMATCH
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Pending import / target contract / target line not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Subscription or line already bound (ALREADY_MAPPED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/pax8/preferences:
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Update Pax8 preferences
      description: |
        Sparse update of the operator-controlled toggles — only fields
        present in the body are touched. An empty body is a no-op
        (status "no-op").
      operationId: updatePax8Preferences
      tags: [Pax8]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                auto_link_companies: {type: boolean, nullable: true}
                auto_link_subscriptions: {type: boolean, nullable: true}
                default_markup_bps: {type: integer, minimum: 0, maximum: 100000, nullable: true}
                default_tax_code_id: {type: string, format: uuid, nullable: true}
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [ok, no-op]}
        "400":
          description: Range violation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/pax8/sync:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Trigger a Pax8 sync now
      description: |
        Fires the per-org sync in the background and returns 202
        immediately. Rate-limited to one trigger per 5 minutes (429
        SYNC_RECENT with Retry-After).
      operationId: syncPax8Now
      tags: [Pax8]
      responses:
        "202":
          description: Sync queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, enum: [queued]}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Connect Pax8 first (NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          description: A sync ran within the last 5 minutes (SYNC_RECENT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Pax8 integration disabled by the platform operator
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/pax8/sync-history:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List Pax8 sync history
      description: |
        Most recent sync-log entries, newest first. `limit` defaults to
        50, max 200 — no page/offset paging.
      operationId: listPax8SyncHistory
      tags: [Pax8]
      parameters:
        - name: limit
          in: query
          schema: {type: integer, default: 50, maximum: 200}
      responses:
        "200":
          description: Sync log entries (null when none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    nullable: true
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        resource_type: {type: string}
                        resource_id: {type: string, format: uuid, nullable: true}
                        external_id: {type: string, nullable: true}
                        action: {type: string}
                        direction: {type: string}
                        status: {type: string}
                        error_message: {type: string, nullable: true}
                        changes:
                          type: object
                          additionalProperties: true
                          description: Raw JSON change set
                        synced_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/qbo:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Get QuickBooks connection status
      description: |
        Sanitised connection view — never carries decrypted tokens.
        Always succeeds: a missing connection row returns
        `connected: false`. `configured` reflects whether the platform
        operator supplied the Intuit app credentials; `blocked_by` names
        another accounts provider (e.g. `freeagent`) holding the
        one-provider-per-tenant slot. `refresh_token_expires_at` tracks
        Intuit's 100-day refresh-token window so the UI can prompt a
        reconnect before API calls start failing.
      operationId: getQboStatus
      tags: [QuickBooks]
      responses:
        "200":
          description: Connection status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      connected:
                        type: boolean
                      configured:
                        type: boolean
                      environment:
                        type: string
                        enum: [sandbox, production]
                      realm_id:
                        type: string
                        description: Intuit company (realm) id, omitted when not connected
                      has_tokens:
                        type: boolean
                      expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      refresh_token_expires_at:
                        type: string
                        format: date-time
                        nullable: true
                      last_sync_at:
                        type: string
                        format: date-time
                        nullable: true
                      last_sync_status:
                        type: string
                      last_sync_error:
                        type: string
                      revoked_at:
                        type: string
                        format: date-time
                        nullable: true
                      blocked_by:
                        type: string
                        description: Other accounts provider already connected (omitted when none)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Disconnect QuickBooks
      description: |
        Best-effort token revoke at Intuit, then deletes the connection
        row, wipes `external_*` sync metadata on
        clients/services/invoices/tax_codes/tax_rates linked to QBO,
        deletes pulled GL accounts and the pending-import queue, and
        invalidates any in-flight OAuth flows. Idempotent: returns 204
        even when no connection exists. Transaction rows keep their
        external ids (immutable ledger / audit trail).
      operationId: disconnectQbo
      tags: [QuickBooks]
      responses:
        "204":
          description: Disconnected (or nothing to disconnect)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/qbo/authorize:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Start QuickBooks OAuth flow
      description: |
        Generates a PKCE verifier and a CSRF `state` bound to the current
        session, and returns the Intuit authorize URL for the SPA to
        navigate to (the API cannot 302 because the Bearer header would
        be lost on a top-level browser redirect). Re-authorizing an
        existing connection is allowed (switching realm, token rotation).
      operationId: authorizeQbo
      tags: [QuickBooks]
      responses:
        "200":
          description: Authorize URL to navigate the browser to
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      authorize_url:
                        type: string
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Another accounts provider is already connected (code PROVIDER_CONFLICT)
          headers:
            X-Accounts-Provider-In-Use:
              description: The provider currently holding the slot
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: QuickBooks credentials not configured by the platform operator (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/qbo/callback:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Complete QuickBooks OAuth flow
      description: |
        Called by the SPA bounce page after Intuit redirects the browser
        back: it reposts the `code`, `state` and `realmId` query
        parameters so the request carries the Bearer token. Consumes the
        state row atomically (single use, session-bound), exchanges the
        code + PKCE verifier for tokens, and persists them encrypted with
        the realm id. Refuses stale flows: a newer completed flow
        (STALE_FLOW), a concurrent other-provider connect
        (PROVIDER_CONFLICT), or a disconnect during the flow
        (OAUTH_INVALIDATED) all return 409.
      operationId: completeQboCallback
      tags: [QuickBooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, state, realm_id]
              properties:
                code:
                  type: string
                  description: OAuth authorization code from the redirect
                state:
                  type: string
                  description: State value issued by POST /authorize
                realm_id:
                  type: string
                  description: Intuit company (realm) id from the redirect querystring
      responses:
        "200":
          description: Connected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [connected]
                      realm_id:
                        type: string
        "400":
          description: Missing fields, or invalid/expired/foreign-session state (codes INVALID_BODY, INVALID_REQUEST, INVALID_STATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Flow refused (codes PROVIDER_CONFLICT, STALE_FLOW, OAUTH_INVALIDATED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Token exchange with Intuit failed (code UPSTREAM_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: QuickBooks integration not configured (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/qbo/create-from-local:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Push a local entity to QuickBooks
      description: |
        Synchronously creates the OpsMerge client (`customer`) or service
        (`item`) as a new QBO entity and links it. Idempotent: retrying
        once linked returns 200 with `status: already_mapped`. Customers
        with an incomplete billing address fail with 400
        ADDRESS_INCOMPLETE naming the missing fields.
      operationId: createQboFromLocal
      tags: [QuickBooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entity_type, local_id]
              properties:
                entity_type:
                  type: string
                  enum: [customer, item]
                local_id:
                  type: string
                  format: uuid
                  description: OpsMerge client or service id
      responses:
        "200":
          description: Created (or already linked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [created, already_mapped]
                      external_id:
                        type: string
                        description: QBO entity id
        "400":
          description: Validation failure or incomplete address (codes VALIDATION_ERROR, ADDRESS_INCOMPLETE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "503":
          description: Sync not wired in this build, or integration not configured (codes SYNC_UNAVAILABLE, NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/qbo/link-local:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Link a local entity to an existing QuickBooks entity
      description: |
        Atomically stamps the QBO id on the local client/service and
        marks the corresponding pending-import row as decided ('map').
        The QBO entity must already exist in the pending-imports queue
        (i.e. it has been pulled at least once); otherwise 400.
        Idempotent on retry. No FreeAgent equivalent exists — use
        PATCH pending-imports/{id} with decision 'map' there.
      operationId: linkQboLocalEntity
      tags: [QuickBooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [entity_type, local_id, qbo_external_id]
              properties:
                entity_type:
                  type: string
                  enum: [customer, item]
                local_id:
                  type: string
                  format: uuid
                  description: OpsMerge client or service id
                qbo_external_id:
                  type: string
                  description: QBO entity id (must exist in the pending-imports queue)
      responses:
        "200":
          description: Linked
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [linked]
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Local entity not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/qbo/lookup-local:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Autocomplete local map targets (QuickBooks)
      description: |
        Autocomplete source for the "Map to existing..." dropdown.
        Searches clients (`customer`) or services (`item`) by name
        substring; prefix matches sort first. Fixed cap of 50 rows, no
        paging.
      operationId: lookupQboLocalEntities
      tags: [QuickBooks]
      parameters:
        - name: entity_type
          in: query
          required: true
          schema:
            type: string
            enum: [customer, item]
        - name: q
          in: query
          description: Name substring (truncated to 100 chars)
          schema: {type: string}
      responses:
        "200":
          description: Matching local entities
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        name:
                          type: string
                        external_id:
                          type: string
                          description: Current external link, omitted when unlinked
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/qbo/pending-imports:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List QuickBooks pending imports
      description: |
        Rows from the import-review queue, most recently seen first.
        Limit-only paging (no offset, no total). Decisions are honoured
        by the sync orchestrator; `payload` carries the raw QBO entity
        JSON so the UI can show fields not surfaced as columns.
      operationId: listQboPendingImports
      tags: [QuickBooks]
      parameters:
        - name: entity_type
          in: query
          description: Filter by entity type; omit for all
          schema:
            type: string
            enum: [customer, item]
        - name: decision
          in: query
          description: Filter by decision; omit for all
          schema:
            type: string
            enum: [pending, create, map, skip, always_skip]
        - name: limit
          in: query
          description: "Max rows, 1-1000; out-of-range values fall back to the default"
          schema: {type: integer, default: 200, maximum: 1000}
      responses:
        "200":
          description: Pending import rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        entity_type:
                          type: string
                          enum: [customer, item]
                        external_id:
                          type: string
                          description: QBO entity id
                        display_name:
                          type: string
                        decision:
                          type: string
                          enum: [pending, create, map, skip, always_skip]
                        mapped_to_local_id:
                          type: string
                          format: uuid
                          nullable: true
                        mapped_local_name:
                          type: string
                        suggested_local_id:
                          type: string
                          format: uuid
                          nullable: true
                        payload:
                          description: Raw QBO entity JSON as last pulled
                        first_seen_at:
                          type: string
                          format: date-time
                        last_seen_at:
                          type: string
                          format: date-time
                        decided_at:
                          type: string
                          format: date-time
                          nullable: true
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/qbo/pending-imports/{id}:
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Decide a QuickBooks pending import
      description: |
        Records the decision and applies it inline in the same
        transaction: `create` upserts a local client/service from the
        cached payload, `map` stamps the QBO id on the chosen local row
        (`mapped_to_local_id` required). Idempotent.
      operationId: updateQboPendingImport
      tags: [QuickBooks]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision:
                  type: string
                  enum: [pending, create, map, skip, always_skip]
                mapped_to_local_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Required when decision is 'map'
      responses:
        "200":
          description: Decision recorded and applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [ok]
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /psa/integrations/qbo/preferences:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Get QuickBooks preferences
      description: Per-tenant auto-create flags. 404 when no connection exists.
      operationId: getQboPreferences
      tags: [QuickBooks]
      responses:
        "200":
          description: Preferences
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      auto_create_customers:
                        type: boolean
                      auto_create_items:
                        type: boolean
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No QuickBooks connection (code NOT_CONNECTED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Update QuickBooks preferences
      description: Partial update. Omitted fields keep their current value.
      operationId: updateQboPreferences
      tags: [QuickBooks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                auto_create_customers:
                  type: boolean
                  nullable: true
                auto_create_items:
                  type: boolean
                  nullable: true
      responses:
        "204":
          description: Preferences saved
        "400":
          description: Malformed body (code INVALID_BODY)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/qbo/sync:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Trigger a manual QuickBooks sync
      description: |
        Enqueues a per-org sync job; the sync itself runs asynchronously
        (poll the status endpoint for the outcome). Cooldown of 5 minutes
        since the last sync — inside the window the call returns 429 with
        `Retry-After: 300`.
      operationId: triggerQboSync
      tags: [QuickBooks]
      responses:
        "202":
          description: Sync job enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      job_id:
                        type: integer
                        format: int64
                      enqueued_for:
                        type: string
                        format: uuid
                        description: Organisation id the sync was enqueued for
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No QuickBooks connection (code NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          description: Synced within the last 5 minutes; see Retry-After (code RATE_LIMITED)
          headers:
            Retry-After:
              description: Seconds until another sync is allowed
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Sync not wired in this build, or integration not configured (codes SYNC_UNAVAILABLE, NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/qbo/sync-history:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List QuickBooks sync history
      description: |
        Rows from the QBO sync log, newest first. Limit-only paging (no
        offset, no total). Filter values outside the allowlists are
        silently ignored (treated as no filter), not rejected.
      operationId: listQboSyncHistory
      tags: [QuickBooks]
      parameters:
        - name: limit
          in: query
          description: "Max rows, 1-200; out-of-range values fall back to the default"
          schema: {type: integer, default: 50, maximum: 200}
        - name: resource_type
          in: query
          schema:
            type: string
            enum: [clients, services, tax_codes, tax_rates, gl_accounts, invoices, transactions]
        - name: status
          in: query
          schema:
            type: string
            enum: [success, error, skipped]
        - name: direction
          in: query
          schema:
            type: string
            enum: [inbound, outbound, noop]
        - name: action
          in: query
          schema:
            type: string
            enum: [create_local, update_local, create_remote, update_remote, void_remote, skip_unchanged, error]
        - name: since
          in: query
          description: Inclusive lower bound on synced_at (RFC 3339)
          schema: {type: string, format: date-time}
        - name: until
          in: query
          description: Inclusive upper bound on synced_at (RFC 3339)
          schema: {type: string, format: date-time}
      responses:
        "200":
          description: Sync log rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        resource_type:
                          type: string
                        external_id:
                          type: string
                          description: Omitted when empty
                        action:
                          type: string
                        direction:
                          type: string
                        status:
                          type: string
                        error_message:
                          type: string
                          description: Omitted when empty
                        synced_at:
                          type: string
                          format: date-time
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/qbo/unmapped-local:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: List local entities unmapped to QuickBooks
      description: |
        Local clients (`customer`) or services (`item`) with no QBO link,
        name-ascending, capped at 200 rows (no paging, no total —
        unlike the FreeAgent twin, which wraps items with a total_count).
      operationId: listQboUnmappedLocal
      tags: [QuickBooks]
      parameters:
        - name: entity_type
          in: query
          required: true
          schema:
            type: string
            enum: [customer, item]
        - name: q
          in: query
          description: Name substring (truncated to 100 chars)
          schema: {type: string}
      responses:
        "200":
          description: Unmapped local entities
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        name:
                          type: string
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/tdsynnex:
    get:
      x-stability: internal
      x-required-scope: psa-products:read
      summary: Get TD Synnex connection
      description: |
        Sanitised connection row — the password surfaces only as
        has_password. Returns 200 with `data: null` when no connection
        exists.
      operationId: getTdsynnexConnection
      tags: [Distributors]
      responses:
        "200":
          description: Connection, or null
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &TDSynnexConnection
                    type: object
                    nullable: true
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      customer_number: {type: string}
                      user_id: {type: string}
                      has_password: {type: boolean}
                      is_sandbox: {type: boolean}
                      last_sync_at: {type: string, format: date-time, nullable: true}
                      last_sync_status: {type: string}
                      last_sync_error: {type: string}
                      revoked_at: {type: string, format: date-time, nullable: true}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      x-required-scope: psa-products:write
      summary: Create or update the TD Synnex connection
      description: |
        Upserts credentials for the TD Synnex UK SOAP P&A service.
        password is only required on first connect or rotation — omit it
        to leave the stored ciphertext unchanged.
      operationId: upsertTdsynnexConnection
      tags: [Distributors]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer_number, user_id]
              properties:
                customer_number: {type: string}
                user_id: {type: string}
                password: {type: string, nullable: true}
                is_sandbox: {type: boolean}
      responses:
        "200":
          description: Saved connection
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TDSynnexConnection
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    delete:
      x-stability: internal
      x-required-scope: psa-products:write
      summary: Disconnect TD Synnex
      description: Wipes credentials. 204 even when nothing existed.
      operationId: deleteTdsynnexConnection
      tags: [Distributors]
      responses:
        "204":
          description: Disconnected
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/integrations/tdsynnex/pa:
    post:
      x-stability: internal
      x-required-scope: psa-products:read
      summary: TD Synnex price and availability lookup
      description: |
        Live P&A lookup against the SOAP service. POST despite being a
        read — the SKU list rides in the body (max 50 per batch).
        Per-SKU failures land in that row's error_message while the
        batch still returns 200.
      operationId: tdsynnexPriceAndAvailability
      tags: [Distributors]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sku_list]
              properties:
                sku_list:
                  type: array
                  maxItems: 50
                  items: {type: string}
      responses:
        "200":
          description: P&A rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    nullable: true
                    items:
                      type: object
                      properties:
                        synnex_sku: {type: string}
                        mfg_pn: {type: string}
                        product_description: {type: string}
                        status: {type: string}
                        price: {type: number}
                        currency_code: {type: string}
                        total_quantity: {type: integer}
                        warehouses:
                          type: array
                          items:
                            type: object
                            properties:
                              city: {type: string}
                              quantity: {type: integer}
                              next_delivery_date: {type: string}
                        error_message: {type: string}
        "400":
          description: sku_list missing, too large, or body invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "412":
          description: TD Synnex not configured for this tenant (NOT_CONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "502":
          description: Upstream TD Synnex call failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/integrations/tdsynnex/test:
    post:
      x-stability: internal
      x-required-scope: psa-products:write
      summary: Test the TD Synnex connection
      description: |
        Verifies credentials by issuing a P&A lookup for a known-absent
        SKU ("auth ok, SKU not found" counts as success). Failures land
        in error_message with success=false (still HTTP 200).
      operationId: testTdsynnexConnection
      tags: [Distributors]
      responses:
        "200":
          description: Test result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      success: {type: boolean}
                      message: {type: string}
                      error_message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/notifications:
    get:
      x-stability: internal
      x-required-scope: psa-rules:read
      summary: List curated PSA notifications
      description: |
        Returns the three curated system-keyed notification cards
        (auto-ack on email ticket, requester public-reply notice,
        assignee comment notice). Missing tenant rows are filled from the
        platform seed defaults so a card is always returned per kind. No
        pagination — the set is fixed.
      operationId: listPsaNotifications
      tags: [Notifications]
      responses:
        "200":
          description: All curated notifications
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &PsaNotification
                          type: object
                          description: One curated (alert template, ticket rule) pair
                          properties:
                            system_key:
                              type: string
                              enum: [auto_ack_email_ticket, notify_requester_on_public_reply, notify_assignee_on_comment]
                            name: {type: string}
                            description: {type: string}
                            trigger_kind: {type: string}
                            enabled: {type: boolean}
                            subject:
                              type: string
                              description: Go text/template subject with {{.var}} substitutions
                            body_html:
                              type: string
                              description: Sanitised HTML body template
                            body_format: {type: string}
                            throttle_seconds: {type: integer}
                            max_fires_per_ticket: {type: integer}
                            business_hours: &BusinessHoursCfg
                              type: object
                              description: Optional send window; outside it the notification is suppressed
                              properties:
                                enabled: {type: boolean}
                                timezone:
                                  type: string
                                  description: IANA tz name (e.g. Europe/London); empty means UTC
                                days:
                                  type: array
                                  description: "ISO weekdays: 1 = Monday … 7 = Sunday"
                                  items: {type: integer, minimum: 1, maximum: 7}
                                start_time:
                                  type: string
                                  description: HH:MM 24h
                                end_time:
                                  type: string
                                  description: HH:MM 24h, exclusive
                            gates:
                              type: object
                              description: Fixed comment gates; read-only in this surface (edit via the raw rules editor)
                              properties:
                                comment_is_public: {type: boolean, nullable: true}
                                comment_author_is_not_requester: {type: boolean, nullable: true}
                                comment_author_is_not_assignee: {type: boolean, nullable: true}
                            available_vars:
                              type: array
                              description: Template variables usable in subject/body for this kind
                              items: {type: string}
                            recipient_pattern:
                              type: string
                              description: Human-readable fixed recipient rule (not editable here)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/notifications/{system_key}:
    parameters:
      - name: system_key
        in: path
        required: true
        schema:
          type: string
          enum: [auto_ack_email_ticket, notify_requester_on_public_reply, notify_assignee_on_comment]
    get:
      x-stability: internal
      x-required-scope: psa-rules:read
      summary: Get one curated notification
      operationId: getPsaNotification
      tags: [Notifications]
      responses:
        "200":
          description: The notification
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PsaNotification
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: internal
      x-required-scope: psa-rules:write
      summary: Update a curated notification
      description: |
        PATCH-style merge despite the PUT verb: every field is optional
        and omitted fields are left unchanged. `body_html` is sanitised
        server-side (strict UGC policy); subject/body templates are
        dry-parsed on save so syntax errors fail with 400
        (TEMPLATE_PARSE_ERROR). Subject and body are capped at 10000
        characters each. A missing tenant row is auto-created from the
        seed defaults before the merge.
      operationId: updatePsaNotification
      tags: [Notifications]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: {type: boolean}
                subject: {type: string, maxLength: 10000}
                body_html: {type: string, maxLength: 10000}
                throttle_seconds:
                  type: integer
                  minimum: 0
                  maximum: 604800
                max_fires_per_ticket:
                  type: integer
                  minimum: 0
                  maximum: 1000
                business_hours: *BusinessHoursCfg
      responses:
        "200":
          description: The merged notification state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PsaNotification
        "400":
          description: Invalid body, template parse error, or out-of-range value
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/procured-items:
    get:
      x-stability: beta
      x-required-scope: psa-procured-items:read
      summary: List client procured items
      description: |
        Returns procured-item rows (physical assets sold to clients),
        most recently purchased first. Uses `limit`/`offset` paging and
        returns a plain array in `data` — no total count. `status` is
        passed through verbatim; an unknown value simply matches
        nothing. `q` substring-matches product name snapshot, serial
        number, and asset tag.
      operationId: listProcuredItems
      tags: [Procurement]
      parameters:
        - name: client_id
          in: query
          description: Restrict to one client (400 if not a UUID)
          schema: {type: string, format: uuid}
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, delivered, installed, retired, returned]
        - name: q
          in: query
          description: "Substring match on product name snapshot, serial number, or asset tag"
          schema: {type: string}
        - name: limit
          in: query
          description: "Max rows, 1-500; out-of-range values fall back to the default"
          schema: {type: integer, default: 100, maximum: 500}
        - name: offset
          in: query
          description: Rows to skip
          schema: {type: integer, default: 0}
      responses:
        "200":
          description: Procured item list (no total count)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &ProcuredItem
                      type: object
                      description: One row from client_procured_items (inventory record, not an RMM agent)
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        client_id: {type: string, format: uuid}
                        site_id: {type: string, format: uuid, nullable: true}
                        product_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Catalogue product that spawned this row; null survives later product deletion
                        product_name_snapshot:
                          type: string
                          description: Frozen at spawn time; survives a later product rename
                        description: {type: string}
                        serial_number: {type: string}
                        asset_tag: {type: string}
                        source_quote_id: {type: string, format: uuid, nullable: true}
                        source_invoice_id: {type: string, format: uuid, nullable: true}
                        purchase_cost_cents:
                          type: integer
                          format: int64
                          nullable: true
                          description: Cost of sale at spawn time, in minor units
                        sale_price_cents: {type: integer, format: int64, nullable: true}
                        currency_code: {type: string, example: GBP}
                        status:
                          type: string
                          enum: [pending, delivered, installed, retired, returned]
                        purchased_at: {type: string, format: date-time}
                        delivered_at: {type: string, format: date-time, nullable: true}
                        warranty_expires_at: {type: string, format: date-time, nullable: true}
                        linked_agent_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: RMM agent matched to this physical device; null for non-agent hardware
                        notes: {type: string}
                        properties:
                          type: object
                          description: Free-form JSON bag
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
                        created_by: {type: string, format: uuid, nullable: true}
              example:
                data:
                  - &ProcuredItemExample
                    id: "8d7c6b5a-4e3f-2d1c-0b9a-8f7e6d5c4b3a"
                    org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                    client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
                    site_id: null
                    product_id: "3f8a1c2b-6d4e-4a7f-9b0c-1d2e3f4a5b6c"
                    product_name_snapshot: "Dell Latitude 5550 i7/16GB/512GB"
                    description: "For new starter in accounts"
                    serial_number: "8KQJ2Y3"
                    asset_tag: "APS-0142"
                    source_quote_id: "4c3d2e1f-0a9b-8c7d-6e5f-4a3b2c1d0e9f"
                    source_invoice_id: null
                    purchase_cost_cents: 89900
                    sale_price_cents: 107880
                    currency_code: GBP
                    status: delivered
                    purchased_at: "2026-06-02T14:00:00Z"
                    delivered_at: "2026-06-06T09:30:00Z"
                    warranty_expires_at: "2029-06-02T00:00:00Z"
                    linked_agent_id: null
                    notes: ""
                    properties: {}
                    created_at: "2026-06-02T14:00:01Z"
                    updated_at: "2026-06-06T09:31:12Z"
                    created_by: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
        "400":
          description: Invalid client_id (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-procured-items:write
      summary: Create a procured item
      description: |
        Creates a manual procured-item stub. Quote accept spawns rows
        automatically for `track_as_asset` products; this endpoint is
        for hardware sold outside that flow. `client_id` and
        `product_name_snapshot` are required. `status` defaults to
        `pending`; values outside the enum are rejected by the database
        constraint.
      operationId: createProcuredItem
      tags: [Procurement]
      requestBody:
        required: true
        content:
          application/json:
            schema: &ProcuredItemUpsert
              type: object
              required: [client_id, product_name_snapshot]
              properties:
                client_id: {type: string, format: uuid}
                site_id: {type: string, format: uuid, nullable: true}
                product_id: {type: string, format: uuid, nullable: true}
                product_name_snapshot: {type: string}
                description: {type: string, nullable: true}
                serial_number: {type: string, nullable: true}
                asset_tag: {type: string, nullable: true}
                purchase_cost_cents:
                  type: integer
                  format: int64
                  nullable: true
                  description: Minor units (pence)
                sale_price_cents: {type: integer, format: int64, nullable: true}
                status:
                  type: string
                  nullable: true
                  enum: [pending, delivered, installed, retired, returned]
                warranty_expires_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: "PATCH semantics: omit the key to leave unchanged; send an explicit null to clear"
                linked_agent_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: "PATCH semantics: omit the key to leave unchanged; send an explicit null to unlink"
                notes: {type: string, nullable: true}
            example:
              client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
              product_id: "3f8a1c2b-6d4e-4a7f-9b0c-1d2e3f4a5b6c"
              product_name_snapshot: "Dell Latitude 5550 i7/16GB/512GB"
              serial_number: "8KQJ2Y3"
              purchase_cost_cents: 89900
              sale_price_cents: 107880
              status: pending
      responses:
        "201":
          description: Procured item created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProcuredItem
              example:
                data: *ProcuredItemExample
        "400":
          description: Missing client_id / product_name_snapshot or invalid body (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/procured-items/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: beta
      x-required-scope: psa-procured-items:read
      summary: Get a procured item
      operationId: getProcuredItem
      tags: [Procurement]
      responses:
        "200":
          description: Procured item
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProcuredItem
              example:
                data: *ProcuredItemExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: beta
      x-required-scope: psa-procured-items:write
      summary: Update a procured item
      description: |
        Sparse update. Omitted fields are left unchanged. For
        `warranty_expires_at` and `linked_agent_id` an explicit JSON
        `null` clears the value (the unlink case), while omitting the
        key leaves it untouched. An empty-string `client_id` cannot be
        sent; `client_id` is only honoured when it is a valid UUID.
      operationId: updateProcuredItem
      tags: [Procurement]
      requestBody:
        required: true
        content:
          application/json:
            schema: *ProcuredItemUpsert
            example:
              status: installed
              linked_agent_id: "0f1e2d3c-4b5a-6f7e-8d9c-0a1b2c3d4e5f"
      responses:
        "200":
          description: Updated procured item
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProcuredItem
              example:
                data: *ProcuredItemExample
        "400":
          description: Invalid body (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-procured-items:write
      summary: Delete a procured item
      operationId: deleteProcuredItem
      tags: [Procurement]
      responses:
        "204":
          description: Deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/products:
    get:
      x-stability: beta
      x-required-scope: psa-products:read
      summary: List products
      description: |
        Returns curated catalogue products sorted by name ascending.
        Uses `limit`/`offset` paging and returns a plain array in
        `data` — no total count. `active` filters to active rows only
        when the literal string `true` is passed. `category` and
        `manufacturer` are exact matches; `q` substring-matches name
        and SKU. Rows never embed `supplier_skus` here — use the GET
        detail for that.
      operationId: listProducts
      tags: [Products]
      parameters:
        - name: active
          in: query
          description: Pass true to return active products only
          schema: {type: boolean}
        - name: category
          in: query
          description: Exact category match
          schema: {type: string}
        - name: manufacturer
          in: query
          description: Exact manufacturer match
          schema: {type: string}
        - name: q
          in: query
          description: Substring match on name or SKU
          schema: {type: string}
        - name: limit
          in: query
          description: "Max rows, 1-500; out-of-range values fall back to the default"
          schema: {type: integer, default: 100, maximum: 500}
        - name: offset
          in: query
          description: Rows to skip
          schema: {type: integer, default: 0}
      responses:
        "200":
          description: Product list (no total count)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: *Product
              example:
                data:
                  - *ProductExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Create a product
      description: |
        Creates a curated catalogue product. `name` is required and
        must be unique per organisation (409 DUPLICATE otherwise; the
        same applies to a duplicate `sku`). Money bounds: cost and sell
        must be 0..the platform line maximum; `markup_bps` must be
        0..1000000. In `markup` mode the sell price is computed
        server-side from cost x markup. A successful create also
        enqueues a QuickBooks Online item push when QBO is connected.
      operationId: createProduct
      tags: [Products]
      requestBody:
        required: true
        content:
          application/json:
            schema: &ProductUpsert
              type: object
              required: [name]
              properties:
                sku:
                  type: string
                  nullable: true
                  description: "On PATCH an empty string clears the SKU"
                name: {type: string}
                description: {type: string, nullable: true}
                category:
                  type: string
                  nullable: true
                  description: "On PATCH an empty string clears the category"
                manufacturer:
                  type: string
                  nullable: true
                  description: "On PATCH an empty string clears the manufacturer"
                pricing_mode:
                  type: string
                  nullable: true
                  enum: [fixed, markup]
                default_cost_cents:
                  type: integer
                  format: int64
                  nullable: true
                  description: Minor units (pence)
                default_sell_cents:
                  type: integer
                  format: int64
                  nullable: true
                  description: Minor units; authoritative in fixed mode, recomputed in markup mode
                markup_bps:
                  type: integer
                  nullable: true
                  description: "Basis points, 0-1000000 (2000 = 20%)"
                currency_code: {type: string, nullable: true}
                tax_code_id: {type: string, format: uuid, nullable: true}
                track_as_asset: {type: boolean, nullable: true}
                is_active: {type: boolean, nullable: true}
            example:
              sku: "LT-DELL-5550"
              name: "Dell Latitude 5550 i7/16GB/512GB"
              description: "15.6in business laptop, 3yr ProSupport"
              category: "Laptops"
              manufacturer: "Dell"
              pricing_mode: markup
              default_cost_cents: 89900
              markup_bps: 2000
              track_as_asset: true
      responses:
        "201":
          description: Product created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Product
              example:
                data: *ProductExample
        "400":
          description: Missing name, pricing out of bounds, or invalid pricing_mode (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: A product with this name or SKU already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/products/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: beta
      x-required-scope: psa-products:read
      summary: Get a product
      description: |
        Returns the product with its `supplier_skus` links embedded so
        the edit surface can render the supplier panel without a second
        round-trip.
      operationId: getProduct
      tags: [Products]
      responses:
        "200":
          description: Product with embedded supplier SKU links
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Product
              example:
                data: *ProductExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Update a product
      description: |
        Sparse update. Omitted fields (and an omitted/empty `name`) are
        left unchanged. Empty strings clear `sku`, `category`, and
        `manufacturer`. Changing cost or markup while in `markup` mode
        recomputes the sell price server-side. A successful update also
        enqueues a QuickBooks Online item push when QBO is connected.
      operationId: updateProduct
      tags: [Products]
      requestBody:
        required: true
        content:
          application/json:
            schema: *ProductUpsert
            example:
              default_cost_cents: 87650
              markup_bps: 2500
      responses:
        "200":
          description: Updated product
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Product
              example:
                data: *ProductExample
        "400":
          description: Pricing out of bounds or invalid pricing_mode (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: A product with this name or SKU already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Delete a product
      description: |
        Hard delete. Supplier SKU links cascade; procured items keep
        their frozen name snapshot with `product_id` set to null.
      operationId: deleteProduct
      tags: [Products]
      responses:
        "204":
          description: Deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/products/{id}/supplier-skus:
    parameters:
      - name: id
        in: path
        required: true
        description: Product id
        schema: {type: string, format: uuid}
    get:
      x-stability: beta
      x-required-scope: psa-products:read
      summary: List a product's supplier SKU links
      description: |
        Returns the bridged supplier SKUs for one product as a plain
        array in `data`. No pagination. An unknown product id returns
        an empty array, not a 404.
      operationId: listProductSupplierSkus
      tags: [Products]
      responses:
        "200":
          description: Supplier SKU links
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: *SupplierSKU
              example:
                data:
                  - &SupplierSKUExample
                    id: "6a5b4c3d-2e1f-0a9b-8c7d-6e5f4a3b2c1d"
                    org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                    product_id: "3f8a1c2b-6d4e-4a7f-9b0c-1d2e3f4a5b6c"
                    supplier_id: "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e"
                    supplier_name: "Northgate Distribution Ltd"
                    supplier_sku: "WC123456"
                    supplier_cost_cents: 87650
                    currency_code: GBP
                    in_stock: true
                    stock_quantity: 14
                    last_refreshed_at: "2026-06-10T06:00:00Z"
                    is_preferred: true
                    created_at: "2026-06-01T10:15:00Z"
                    updated_at: "2026-06-10T06:00:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Link a supplier SKU to a product
      description: |
        Attaches a supplier SKU to the product. `supplier_id` and
        `supplier_sku` are required; a `supplier_id` from another
        tenant returns 400 INVALID_REFERENCE. This is an upsert on
        (supplier, SKU): posting a SKU already linked elsewhere
        re-points it at this product rather than failing. Setting
        `is_preferred` clears the preferred flag from the product's
        other links. `currency_code` is fixed at GBP server-side.
      operationId: linkProductSupplierSku
      tags: [Products]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [supplier_id, supplier_sku]
              properties:
                supplier_id: {type: string, format: uuid}
                supplier_sku: {type: string}
                supplier_cost_cents:
                  type: integer
                  format: int64
                  nullable: true
                  description: Minor units; on upsert a null keeps the previously-known cost
                is_preferred: {type: boolean, default: false}
            example:
              supplier_id: "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e"
              supplier_sku: "WC123456"
              supplier_cost_cents: 87650
              is_preferred: true
      responses:
        "201":
          description: Link created (or re-pointed by upsert)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SupplierSKU
              example:
                data: *SupplierSKUExample
        "400":
          description: Missing supplier_id/supplier_sku (VALIDATION_ERROR) or supplier not found in this tenant (INVALID_REFERENCE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/products/{id}/supplier-skus/{link_id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Product id (not validated against the link)
        schema: {type: string, format: uuid}
      - name: link_id
        in: path
        required: true
        description: Supplier SKU link id
        schema: {type: string, format: uuid}
    delete:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Unlink a supplier SKU from a product
      description: |
        Deletes one supplier SKU link by its own id. The curated
        product and the raw feed item both survive — only the bridge
        row is removed.
      operationId: unlinkProductSupplierSku
      tags: [Products]
      responses:
        "204":
          description: Unlinked
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/project-templates:
    get:
      x-stability: beta
      x-required-scope: psa-project-templates:read
      summary: List project templates
      description: |
        Returns project templates for the organisation. No pagination —
        the full filtered set comes back as `{items, total}`. Archived
        templates are hidden unless `include_archived=true`. Payloads
        are not included; fetch a single template for its current
        version payload.
      operationId: listProjectTemplates
      tags: [Project Templates]
      parameters:
        - name: include_archived
          in: query
          description: Include soft-deleted (archived) templates
          schema: {type: boolean}
        - name: category
          in: query
          description: Exact category match
          schema: {type: string}
        - name: is_seed
          in: query
          description: Filter to (or away from) imported sample-catalogue templates
          schema: {type: boolean}
        - name: q
          in: query
          description: Case-insensitive substring search across name and code
          schema: {type: string}
      responses:
        "200":
          description: Template list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &ProjectTemplate
                          type: object
                          description: One row from the project_templates table (metadata; the payload lives in versions)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            code:
                              type: string
                              description: Stable identifier; auto-generated from the name when not supplied
                            name: {type: string}
                            description: {type: string, nullable: true}
                            category: {type: string, nullable: true}
                            color: {type: string, nullable: true}
                            icon: {type: string, nullable: true}
                            current_version:
                              type: integer
                              description: Version counter; bumped by each payload save
                            is_seed:
                              type: boolean
                              description: true when the template came from the built-in sample catalogue
                            archived_at: {type: string, format: date-time, nullable: true}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                            created_by: {type: string, format: uuid, nullable: true}
                      total:
                        type: integer
                        description: Number of rows returned (no pagination)
              example:
                data:
                  items:
                    - &ProjectTemplateExample
                      id: "3f9d2a1b-5c6e-4d7f-8a9b-0c1d2e3f4a5b"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      code: "client-onboarding"
                      name: "Client Onboarding"
                      description: "Standard 4-phase onboarding for new managed clients"
                      category: "Onboarding"
                      color: "#1976d2"
                      icon: "rocket_launch"
                      current_version: 3
                      is_seed: false
                      archived_at: null
                      created_at: "2026-05-02T10:00:00Z"
                      updated_at: "2026-06-01T14:30:00Z"
                      created_by: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                  total: 1
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-project-templates:write
      summary: Create a project template
      description: |
        Creates a template plus its version-1 payload in one call. When
        `code` is omitted a stable code is generated from the name. The
        payload is structurally validated (phase keys unique and
        non-empty, sequences sane) before the row is written.
      operationId: createProjectTemplate
      tags: [Project Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, payload]
              properties:
                code: {type: string, nullable: true}
                name: {type: string}
                description: {type: string, nullable: true}
                category: {type: string, nullable: true}
                color: {type: string, nullable: true}
                icon: {type: string, nullable: true}
                payload: &TemplatePayload
                  type: object
                  description: |
                    Structured template payload. String fields support Go
                    template variable substitution (e.g. {{.client.name}})
                    resolved at instantiation time; see
                    GET /psa/project-templates/available-vars.
                  properties:
                    project_defaults:
                      type: object
                      description: Seeds the project row on instantiation; every field optional
                      properties:
                        billing_model:
                          type: string
                          enum: [time_and_materials, fixed_price, milestone, retainer_drawdown, non_billable]
                        budget_hours: {type: number}
                        currency_code: {type: string}
                        duration_days:
                          type: integer
                          description: Offset between project start_date and target_end_date when the operator supplies none
                        color: {type: string}
                        description:
                          type: string
                          description: Rendered with variable substitution at instantiation time
                    phases:
                      type: array
                      items:
                        type: object
                        properties:
                          key:
                            type: string
                            description: Stable identifier for cross-version diffing; survives renames
                          name: {type: string}
                          description: {type: string}
                          sequence: {type: integer}
                          gates_next: {type: boolean}
                          duration_days:
                            type: integer
                            description: Seeds the phase target_end_date relative to the cumulative start
                          tasks:
                            type: array
                            description: Each task instantiates as a ticket with project_id + phase_id set
                            items:
                              type: object
                              properties:
                                key: {type: string}
                                subject: {type: string}
                                description: {type: string}
                                estimated_hours: {type: number}
                                default_priority: {type: string}
                                default_type: {type: string}
                                due_offset_days:
                                  type: integer
                                  description: Days from the project start_date for the task due date
                          milestones:
                            type: array
                            items:
                              type: object
                              properties:
                                key: {type: string}
                                name: {type: string}
                                description: {type: string}
                                due_offset_days: {type: integer}
                                billing_trigger: {type: boolean}
                                billing_amount_cents: {type: integer, format: int64}
            example:
              name: "Client Onboarding"
              category: "Onboarding"
              payload:
                project_defaults:
                  billing_model: fixed_price
                  duration_days: 30
                  description: "Standard onboarding for {{.client.name}}"
                phases:
                  - key: discovery
                    name: "Discovery"
                    sequence: 1
                    duration_days: 5
                    tasks:
                      - key: site-survey
                        subject: "Site survey for {{.client.name}}"
                        estimated_hours: 4
                        due_offset_days: 3
                    milestones:
                      - key: kickoff
                        name: "Kickoff complete"
                        due_offset_days: 5
      responses:
        "201":
          description: Template and version 1 created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      template: *ProjectTemplate
                      current: &TemplateVersion
                        type: object
                        description: One row from project_template_versions; a complete payload snapshot
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          template_id: {type: string, format: uuid}
                          version: {type: integer}
                          payload: *TemplatePayload
                          change_summary: {type: string, nullable: true}
                          created_at: {type: string, format: date-time}
                          created_by: {type: string, format: uuid, nullable: true}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Template code already exists (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/project-templates/available-vars:
    get:
      x-stability: beta
      x-required-scope: psa-project-templates:read
      summary: List template substitution variables
      description: |
        Returns the variable namespace the payload editor surfaces in its
        "available variables" helper. Every listed variable resolves at
        instantiation time; unresolvable references render as empty
        strings rather than erroring.
      operationId: listProjectTemplateVars
      tags: [Project Templates]
      responses:
        "200":
          description: Variable list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      vars:
                        type: array
                        items: {type: string}
              example:
                data:
                  vars:
                    - "{{.client.name}}"
                    - "{{.client.code}}"
                    - "{{.user.name}}"
                    - "{{.date.today}}"
                    - "{{.project.code}}"
                    - "{{.project.name}}"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/project-templates/import-samples:
    post:
      x-stability: beta
      x-required-scope: psa-project-templates:write
      summary: Import the sample template catalogue
      description: |
        Persists the built-in curated sample templates (is_seed=true)
        into the organisation. Idempotent — templates whose code already
        exists are skipped, so `created_count` may be lower than
        `available` on re-runs. Takes no request body.
      operationId: importSampleProjectTemplates
      tags: [Project Templates]
      responses:
        "200":
          description: Import result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      created:
                        type: array
                        description: Templates created by this call
                        items: *ProjectTemplate
                      created_count: {type: integer}
                      available:
                        type: integer
                        description: Total size of the sample catalogue
              example:
                data:
                  created: []
                  created_count: 0
                  available: 6
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/project-templates/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-project-templates:read
      summary: Get a project template with its current version
      description: Returns the template metadata bundled with the current version's payload.
      operationId: getProjectTemplate
      tags: [Project Templates]
      responses:
        "200":
          description: Template bundle
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      template: *ProjectTemplate
                      current:
                        allOf:
                          - *TemplateVersion
                        nullable: true
                        description: Null only in the degenerate case of a template with no versions
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: beta
      x-required-scope: psa-project-templates:write
      summary: Update template metadata
      description: |
        Metadata-only patch (code, name, description, category, color,
        icon). The payload is versioned separately via
        PUT /psa/project-templates/{id}/payload. Nullable fields use
        tri-state JSON semantics: absent = leave unchanged, null = clear,
        value = set.
      operationId: updateProjectTemplate
      tags: [Project Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                code: {type: string}
                name: {type: string}
                description: {type: string, nullable: true}
                category: {type: string, nullable: true}
                color: {type: string, nullable: true}
                icon: {type: string, nullable: true}
            example:
              name: "Client Onboarding v2"
              category: "Onboarding"
      responses:
        "200":
          description: Updated template (metadata only; no version payload)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProjectTemplate
              example:
                data: *ProjectTemplateExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Template code already exists (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-project-templates:write
      summary: Archive a project template
      description: Soft-delete — sets archived_at. Existing projects instantiated from the template are unaffected.
      operationId: deleteProjectTemplate
      tags: [Project Templates]
      responses:
        "204":
          description: Template archived
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/project-templates/{id}/payload:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    put:
      x-stability: beta
      x-required-scope: psa-project-templates:write
      summary: Save a new template payload version
      description: |
        Writes a new project_template_versions row and bumps the
        template's current_version counter. Versions are immutable
        snapshots — there is no in-place payload edit. Body limit 2 MiB.
      operationId: saveProjectTemplatePayload
      tags: [Project Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payload]
              properties:
                payload: *TemplatePayload
                change_summary: {type: string, nullable: true}
            example:
              change_summary: "Added security-review task to discovery phase"
              payload:
                project_defaults:
                  billing_model: fixed_price
                phases:
                  - key: discovery
                    name: "Discovery"
                    sequence: 1
      responses:
        "200":
          description: Template with the freshly written version
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      template: *ProjectTemplate
                      current: *TemplateVersion
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/project-templates/{id}/versions:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-project-templates:read
      summary: List template version history
      description: Returns every version snapshot for the template, no pagination.
      operationId: listProjectTemplateVersions
      tags: [Project Templates]
      responses:
        "200":
          description: Version list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *TemplateVersion
                      total:
                        type: integer
                        description: Number of rows returned (no pagination)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/project-templates/{id}/versions/{version}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: version
        in: path
        required: true
        description: 1-based version number
        schema:
          type: integer
          minimum: 1
    get:
      x-stability: beta
      x-required-scope: psa-project-templates:read
      summary: Get one template version
      operationId: getProjectTemplateVersion
      tags: [Project Templates]
      responses:
        "200":
          description: Version snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TemplateVersion
        "400":
          description: Version is not a positive integer (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects:
    get:
      x-stability: beta
      x-required-scope: psa-projects:read
      summary: List projects
      description: |
        Returns projects for the organisation as `{items, total}`. No
        pagination — the full filtered set comes back in one response.
        Archived (soft-deleted) projects are hidden unless
        `include_archived=true`.
      operationId: listProjects
      tags: [Projects]
      parameters:
        - name: include_archived
          in: query
          description: Include archived projects
          schema: {type: boolean}
        - name: status
          in: query
          schema:
            type: string
            enum: [planning, active, on_hold, completed, cancelled]
        - name: billing_model
          in: query
          schema:
            type: string
            enum: [time_and_materials, fixed_price, milestone, retainer_drawdown, non_billable]
        - name: client_id
          in: query
          schema: {type: string, format: uuid}
        - name: project_manager_id
          in: query
          schema: {type: string, format: uuid}
        - name: q
          in: query
          description: Free-text search across name and code
          schema: {type: string}
      responses:
        "200":
          description: Project list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &Project
                          type: object
                          description: |
                            One row from the projects table. Status lifecycle:
                            planning -> active -> completed, with on_hold and
                            cancelled as side exits; transitions are free-form
                            (no server-side state machine). health is a
                            server-computed RAG signal — never set it directly.
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            code:
                              type: string
                              description: "Auto-generated PRJ-YYYY-NNNN when not supplied on create; immutable thereafter"
                            name: {type: string}
                            description: {type: string, nullable: true}
                            status:
                              type: string
                              enum: [planning, active, on_hold, completed, cancelled]
                            health:
                              type: string
                              enum: [green, amber, red, unknown]
                              description: Computed RAG signal; refreshed server-side
                            client_id: {type: string, format: uuid, nullable: true}
                            site_id: {type: string, format: uuid, nullable: true}
                            owner_user_id: {type: string, format: uuid, nullable: true}
                            project_manager_id: {type: string, format: uuid, nullable: true}
                            project_template_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Set when the project was instantiated from a template
                            project_template_version: {type: integer, nullable: true}
                            billing_model:
                              type: string
                              enum: [time_and_materials, fixed_price, milestone, retainer_drawdown, non_billable]
                            budget_hours: {type: number, nullable: true}
                            budget_revenue_cents: {type: integer, format: int64, nullable: true}
                            currency_code:
                              type: string
                              description: ISO 4217 code, default GBP
                            start_date: {type: string, format: date-time, nullable: true}
                            target_end_date: {type: string, format: date-time, nullable: true}
                            actual_end_date: {type: string, format: date-time, nullable: true}
                            color: {type: string, nullable: true}
                            properties:
                              type: object
                              description: Free-form JSON property bag
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                            created_by: {type: string, format: uuid, nullable: true}
                            archived_at: {type: string, format: date-time, nullable: true}
                      total:
                        type: integer
                        description: Number of rows returned (no pagination)
              example:
                data:
                  items:
                    - &ProjectExample
                      id: "6c8e0a2b-4d5f-6a7b-8c9d-0e1f2a3b4c5d"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      code: "PRJ-2026-0007"
                      name: "Greendale Surveyors onboarding"
                      description: "Migrate 24 endpoints and set up M365 baseline"
                      status: active
                      health: green
                      client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
                      site_id: null
                      owner_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      project_manager_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      project_template_id: "3f9d2a1b-5c6e-4d7f-8a9b-0c1d2e3f4a5b"
                      project_template_version: 3
                      billing_model: fixed_price
                      budget_hours: 80
                      budget_revenue_cents: 850000
                      currency_code: GBP
                      start_date: "2026-06-01T00:00:00Z"
                      target_end_date: "2026-07-01T00:00:00Z"
                      actual_end_date: null
                      color: "#1976d2"
                      properties: {}
                      created_at: "2026-05-28T09:00:00Z"
                      updated_at: "2026-06-10T16:20:00Z"
                      created_by: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      archived_at: null
                  total: 1
        "400":
          description: Invalid filter value (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Create a project
      description: |
        Creates a project and auto-creates a "Main" phase so tasks always
        have a phase to land in. `code` is generated as PRJ-YYYY-NNNN when
        omitted; a supplied code that already exists returns 409.
        Referenced clients/sites/users must belong to the caller's org
        (400 INVALID_REFERENCE otherwise). Returns a Location header for
        the new project.
      operationId: createProject
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                code: {type: string, nullable: true}
                name: {type: string}
                description: {type: string, nullable: true}
                status:
                  type: string
                  enum: [planning, active, on_hold, completed, cancelled]
                  default: planning
                client_id: {type: string, format: uuid, nullable: true}
                site_id: {type: string, format: uuid, nullable: true}
                owner_user_id: {type: string, format: uuid, nullable: true}
                project_manager_id: {type: string, format: uuid, nullable: true}
                billing_model:
                  type: string
                  enum: [time_and_materials, fixed_price, milestone, retainer_drawdown, non_billable]
                  default: time_and_materials
                budget_hours: {type: number, nullable: true}
                budget_revenue_cents: {type: integer, format: int64, nullable: true}
                currency_code:
                  type: string
                  description: 3-letter ISO 4217 code; defaults to GBP
                start_date: {type: string, format: date-time, nullable: true}
                target_end_date: {type: string, format: date-time, nullable: true}
                color: {type: string, nullable: true}
            example:
              name: "Greendale Surveyors onboarding"
              client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
              billing_model: fixed_price
              budget_hours: 80
              budget_revenue_cents: 850000
              start_date: "2026-06-01T00:00:00Z"
              target_end_date: "2026-07-01T00:00:00Z"
      responses:
        "201":
          description: Project created (with its auto-created Main phase)
          headers:
            Location:
              description: URL of the created project
              schema: {type: string}
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      project: *Project
                      main_phase: &ProjectPhase
                        type: object
                        description: |
                          One row from the project_phases table. Status
                          flow: pending -> active -> completed, or skipped;
                          transitions are free-form. gates_next marks the
                          phase as a stage gate for the next one.
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          project_id: {type: string, format: uuid}
                          name: {type: string}
                          description: {type: string, nullable: true}
                          sequence:
                            type: integer
                            description: 1-based ordering within the project; unique per project
                          status:
                            type: string
                            enum: [pending, active, completed, skipped]
                          gates_next: {type: boolean}
                          start_date: {type: string, format: date-time, nullable: true}
                          target_end_date: {type: string, format: date-time, nullable: true}
                          actual_end_date: {type: string, format: date-time, nullable: true}
                          properties:
                            type: object
                            description: Free-form JSON property bag
                          created_at: {type: string, format: date-time}
                          updated_at: {type: string, format: date-time}
        "400":
          description: Validation failure or cross-org reference (VALIDATION_ERROR / INVALID_REFERENCE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Project code already exists (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/from-template:
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Instantiate a project from a template
      description: |
        Renders the template's current version against substitution
        variables (client, user, date, project), then creates the
        project, its phases, one ticket per template task, and its
        milestones in a single transaction. Budget overrides beat the
        template's project_defaults. Lives under /psa/projects because
        the resulting resource is a project; requires the projects
        write scope, not the templates one.
      operationId: instantiateProjectFromTemplate
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template_id, name]
              properties:
                template_id: {type: string, format: uuid}
                name: {type: string}
                description: {type: string, nullable: true}
                client_id: {type: string, format: uuid, nullable: true}
                site_id: {type: string, format: uuid, nullable: true}
                owner_user_id: {type: string, format: uuid, nullable: true}
                project_manager_id: {type: string, format: uuid, nullable: true}
                start_date: {type: string, format: date-time, nullable: true}
                budget_hours_override: {type: number, nullable: true}
                budget_revenue_cents_override: {type: integer, format: int64, nullable: true}
            example:
              template_id: "3f9d2a1b-5c6e-4d7f-8a9b-0c1d2e3f4a5b"
              name: "Greendale Surveyors onboarding"
              client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
              start_date: "2026-06-15T00:00:00Z"
      responses:
        "201":
          description: Project instantiated; task IDs are not returned (fetch via /psa/tickets?project_id=...)
          headers:
            Location:
              description: URL of the created project
              schema: {type: string}
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      project: *Project
                      phases:
                        type: array
                        items: *ProjectPhase
                      milestones:
                        type: array
                        items: &ProjectMilestone
                          type: object
                          description: |
                            One row from the project_milestones table.
                            Status flow: pending -> reached (terminal for
                            billing) with at_risk / missed / cancelled as
                            operator-set states. Setting status to
                            "reached" on a milestone with billing_trigger
                            appends an invoice line exactly once
                            (billing_invoiced_at stamps the row).
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            project_id: {type: string, format: uuid}
                            phase_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Null for project-level milestones; phase deletion demotes to project-level
                            name: {type: string}
                            description: {type: string, nullable: true}
                            due_at: {type: string, format: date-time, nullable: true}
                            status:
                              type: string
                              enum: [pending, at_risk, reached, missed, cancelled]
                            reached_at: {type: string, format: date-time, nullable: true}
                            billing_trigger: {type: boolean}
                            billing_amount_cents: {type: integer, format: int64, nullable: true}
                            billing_invoiced_at:
                              type: string
                              format: date-time
                              nullable: true
                              description: Set once the milestone has been billed; makes re-billing idempotent
                            properties:
                              type: object
                              description: Free-form JSON property bag
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      tasks_created:
                        type: integer
                        description: Count of tickets created from template tasks
        "400":
          description: Validation failure or cross-org reference (VALIDATION_ERROR / INVALID_REFERENCE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Template not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/portfolio:
    get:
      x-stability: beta
      x-required-scope: psa-projects:read
      summary: Portfolio rollup across all projects
      description: |
        Returns every non-archived project with computed health metrics
        (burn, task counts, open risks, pending approvals, next
        milestone) plus a top-line summary for the dashboard hero.
        health_signal is computed server-side: green when everything is
        fine; amber when burn >= 80%, tasks are overdue, or risks are
        open; red when burn >= 100%, approvals are overdue, or the
        project is on hold.
      operationId: getProjectPortfolio
      tags: [Projects]
      responses:
        "200":
          description: Portfolio rows + summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            project: *Project
                            budgeted_hours: {type: number, nullable: true}
                            logged_hours: {type: number}
                            burn_pct:
                              type: number
                              nullable: true
                              description: logged / budgeted * 100; null when the project has no hours budget
                            tasks_total: {type: integer}
                            tasks_open: {type: integer}
                            tasks_overdue: {type: integer}
                            open_risks: {type: integer}
                            pending_approvals: {type: integer}
                            next_milestone_at: {type: string, format: date-time, nullable: true}
                            next_milestone_name: {type: string, nullable: true}
                            health_signal:
                              type: string
                              enum: [green, amber, red]
                      total: {type: integer}
                      summary:
                        type: object
                        properties:
                          green: {type: integer}
                          amber: {type: integer}
                          red: {type: integer}
                          open_risks: {type: integer}
                          pending_approvals: {type: integer}
                          overdue_tasks: {type: integer}
              example:
                data:
                  items:
                    - project: *ProjectExample
                      budgeted_hours: 80
                      logged_hours: 34.5
                      burn_pct: 43.1
                      tasks_total: 18
                      tasks_open: 7
                      tasks_overdue: 0
                      open_risks: 1
                      pending_approvals: 0
                      next_milestone_at: "2026-06-20T00:00:00Z"
                      next_milestone_name: "Endpoint migration complete"
                      health_signal: amber
                  total: 1
                  summary:
                    green: 0
                    amber: 1
                    red: 0
                    open_risks: 1
                    pending_approvals: 0
                    overdue_tasks: 0
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-projects:read
      summary: Get a project with phases and milestones
      description: Returns the project plus its full phase and milestone lists in one call. Tasks are fetched separately via /psa/tickets?project_id=...
      operationId: getProject
      tags: [Projects]
      responses:
        "200":
          description: Project detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      project: *Project
                      phases:
                        type: array
                        items: *ProjectPhase
                      milestones:
                        type: array
                        items: *ProjectMilestone
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Update a project
      description: |
        Partial update. Nullable fields use tri-state JSON semantics:
        absent = leave unchanged, null = clear to NULL, value = set.
        `code` and `health` cannot be changed here (code is immutable
        after create; health is computed server-side). Status changes
        are free-form — there is no enforced transition order.
      operationId: updateProject
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                description: {type: string, nullable: true}
                status:
                  type: string
                  enum: [planning, active, on_hold, completed, cancelled]
                client_id: {type: string, format: uuid, nullable: true}
                site_id: {type: string, format: uuid, nullable: true}
                owner_user_id: {type: string, format: uuid, nullable: true}
                project_manager_id: {type: string, format: uuid, nullable: true}
                billing_model:
                  type: string
                  enum: [time_and_materials, fixed_price, milestone, retainer_drawdown, non_billable]
                budget_hours: {type: number, nullable: true}
                budget_revenue_cents: {type: integer, format: int64, nullable: true}
                currency_code: {type: string}
                start_date: {type: string, format: date-time, nullable: true}
                target_end_date: {type: string, format: date-time, nullable: true}
                actual_end_date: {type: string, format: date-time, nullable: true}
                color: {type: string, nullable: true}
            example:
              status: completed
              actual_end_date: "2026-06-28T00:00:00Z"
      responses:
        "200":
          description: Updated project
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Project
              example:
                data: *ProjectExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Archive a project
      description: Soft-delete — sets archived_at. Tasks keep their project linkage; the project disappears from default lists. Archived projects refuse new phases/milestones (409 on those creates).
      operationId: deleteProject
      tags: [Projects]
      responses:
        "204":
          description: Project archived
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/approvals:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-projects:read
      summary: List approvals for a project
      description: Returns every approval object for the project, no pagination.
      operationId: listProjectApprovals
      tags: [Projects]
      responses:
        "200":
          description: Approval list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &ProjectApproval
                          type: object
                          description: |
                            One row from the project_approvals table.
                            Approval-as-an-object: created as pending, then
                            decided exactly once via the /decide endpoint to
                            approved, rejected, or cancelled. Decided
                            approvals are immutable — revising means
                            creating a new one.
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            project_id: {type: string, format: uuid}
                            phase_id: {type: string, format: uuid, nullable: true}
                            milestone_id: {type: string, format: uuid, nullable: true}
                            title: {type: string}
                            description: {type: string, nullable: true}
                            approver_user_id: {type: string, format: uuid, nullable: true}
                            requested_by: {type: string, format: uuid, nullable: true}
                            due_at: {type: string, format: date-time, nullable: true}
                            status:
                              type: string
                              enum: [pending, approved, rejected, cancelled]
                            decided_at: {type: string, format: date-time, nullable: true}
                            decision_note: {type: string, nullable: true}
                            properties:
                              type: object
                              description: Free-form JSON property bag
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      total:
                        type: integer
                        description: Number of rows returned (no pagination)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Create an approval request
      description: Creates a pending approval, optionally scoped to a phase or milestone.
      operationId: createProjectApproval
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                phase_id: {type: string, format: uuid, nullable: true}
                milestone_id: {type: string, format: uuid, nullable: true}
                title: {type: string}
                description: {type: string, nullable: true}
                approver_user_id: {type: string, format: uuid, nullable: true}
                due_at: {type: string, format: date-time, nullable: true}
            example:
              title: "Sign off discovery phase"
              approver_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
              due_at: "2026-06-18T17:00:00Z"
      responses:
        "201":
          description: Approval created (status pending)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProjectApproval
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/approvals/{approval_id}/decide:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: approval_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Decide an approval
      description: |
        One-shot transition from pending to approved, rejected, or
        cancelled (deciding back to pending is rejected with 400).
        Stamps decided_at and decision_note. An already-decided approval
        returns 409 — decisions are immutable; create a new approval to
        revise. Requires the psa.projects.approve permission (mapped to
        the psa-projects:write scope for API keys).
      operationId: decideProjectApproval
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [approved, rejected, cancelled]
                decision_note: {type: string, nullable: true}
            example:
              status: approved
              decision_note: "Scope agreed on the 12 June call"
      responses:
        "200":
          description: Decided approval
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProjectApproval
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Approval already decided (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/burn-rate:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-projects:read
      summary: Project burn-rate
      description: Aggregates time entries and invoice items for the project. Computed on demand — no caching.
      operationId: getProjectBurnRate
      tags: [Projects]
      responses:
        "200":
          description: Burn-rate figures
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      budgeted_hours: {type: number, nullable: true}
                      logged_hours: {type: number}
                      burn_pct:
                        type: number
                        nullable: true
                        description: logged / budgeted * 100; null when no hours budget is set
                      budget_revenue_cents: {type: integer, format: int64, nullable: true}
                      billed_revenue_cents: {type: integer, format: int64}
                      currency_code: {type: string}
                      health_signal:
                        type: string
                        enum: [green, amber, red]
              example:
                data:
                  budgeted_hours: 80
                  logged_hours: 34.5
                  burn_pct: 43.1
                  budget_revenue_cents: 850000
                  billed_revenue_cents: 250000
                  currency_code: GBP
                  health_signal: green
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/dependencies:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-projects:read
      summary: List task dependencies for a project
      description: Returns every dependency edge between the project's tasks, no pagination. Only finish-to-start edges exist in v1.
      operationId: listProjectDependencies
      tags: [Projects]
      responses:
        "200":
          description: Dependency list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &ProjectDependency
                          type: object
                          description: One finish-to-start edge between two project task tickets
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            predecessor_ticket_id: {type: string, format: uuid}
                            successor_ticket_id: {type: string, format: uuid}
                            dep_type:
                              type: string
                              enum: [finish_to_start]
                              description: Only FS edges in v1 (SS/FF/SF deferred)
                            lag_days: {type: integer}
                            created_at: {type: string, format: date-time}
                            created_by: {type: string, format: uuid, nullable: true}
                      total:
                        type: integer
                        description: Number of rows returned (no pagination)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Create a task dependency
      description: |
        Adds a finish-to-start edge between two of the project's task
        tickets. An edge that would create a cycle in the dependency
        graph is rejected with 409.
      operationId: createProjectDependency
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [predecessor_ticket_id, successor_ticket_id]
              properties:
                predecessor_ticket_id: {type: string, format: uuid}
                successor_ticket_id: {type: string, format: uuid}
                lag_days:
                  type: integer
                  default: 0
            example:
              predecessor_ticket_id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
              successor_ticket_id: "1c7a2b0d-3e75-4a8f-0c2b-8e7a4b3c2d12"
              lag_days: 2
      responses:
        "201":
          description: Dependency created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProjectDependency
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Edge would create a cycle in the dependency graph (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/dependencies/{dep_id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: dep_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Delete a task dependency
      operationId: deleteProjectDependency
      tags: [Projects]
      responses:
        "204":
          description: Dependency deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/discovery/scan:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Run a discovery scan
      description: |
        Walks known data sources for the project's client and creates
        one project task (ticket) per gap found. Idempotent — re-running
        skips findings already tracked via stable source-id matching
        (counted in tickets_skipped). Takes no request body.
      operationId: scanProjectDiscovery
      tags: [Projects]
      responses:
        "200":
          description: Scan result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      findings_scanned: {type: integer}
                      tickets_created:
                        type: array
                        items:
                          type: object
                          properties:
                            ticket_id: {type: string, format: uuid}
                            subject: {type: string}
                            source: {type: string}
                      tickets_skipped:
                        type: integer
                        description: Findings already tracked from a previous scan
              example:
                data:
                  findings_scanned: 14
                  tickets_created:
                    - ticket_id: "1c7a2b0d-3e75-4a8f-0c2b-8e7a4b3c2d12"
                      subject: "Agent offline > 30 days: RECEPTION-PC"
                      source: agents
                  tickets_skipped: 13
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/milestones:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-projects:read
      summary: List milestones for a project
      description: Returns every milestone for the project, no pagination.
      operationId: listProjectMilestones
      tags: [Projects]
      responses:
        "200":
          description: Milestone list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *ProjectMilestone
                      total:
                        type: integer
                        description: Number of rows returned (no pagination)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Create a milestone
      description: Creates a milestone, optionally pinned to a phase. Refused with 409 when the project is archived.
      operationId: createProjectMilestone
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                phase_id: {type: string, format: uuid, nullable: true}
                name: {type: string}
                description: {type: string, nullable: true}
                due_at: {type: string, format: date-time, nullable: true}
                status:
                  type: string
                  enum: [pending, at_risk, reached, missed, cancelled]
                  default: pending
                billing_trigger:
                  type: boolean
                  default: false
                billing_amount_cents: {type: integer, format: int64, nullable: true}
            example:
              name: "Endpoint migration complete"
              due_at: "2026-06-20T00:00:00Z"
              billing_trigger: true
              billing_amount_cents: 425000
      responses:
        "201":
          description: Milestone created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProjectMilestone
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Project is archived (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/milestones/{m_id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: m_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Update a milestone (may trigger billing)
      description: |
        Partial update with tri-state JSON semantics on nullable fields
        (absent = unchanged, null = clear, value = set). Flipping status
        to "reached" on a milestone with billing_trigger appends an
        invoice line exactly once — the response's `billing` object
        carries the invoice detail when that fired, and is null
        otherwise. Billing failures never roll back the milestone
        change; they are logged and `billing` stays null.
      operationId: updateProjectMilestone
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                phase_id: {type: string, format: uuid, nullable: true}
                name: {type: string}
                description: {type: string, nullable: true}
                due_at: {type: string, format: date-time, nullable: true}
                status:
                  type: string
                  enum: [pending, at_risk, reached, missed, cancelled]
                reached_at: {type: string, format: date-time, nullable: true}
                billing_trigger: {type: boolean}
                billing_amount_cents: {type: integer, format: int64, nullable: true}
            example:
              status: reached
      responses:
        "200":
          description: Updated milestone plus billing side-effect detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      milestone: *ProjectMilestone
                      billing:
                        type: object
                        nullable: true
                        description: Present only when this update appended a milestone billing line
                        properties:
                          invoice_id: {type: string, format: uuid}
                          invoice_number: {type: string}
                          invoice_item_id: {type: string, format: uuid}
                          amount_cents: {type: integer, format: int64}
                          currency_code: {type: string}
                          is_new_invoice:
                            type: boolean
                            description: true when a new draft invoice was created to hold the line
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Delete a milestone
      description: Hard delete. Refused with 409 when the store layer reports a validation conflict.
      operationId: deleteProjectMilestone
      tags: [Projects]
      responses:
        "204":
          description: Milestone deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Milestone cannot be deleted (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/phases:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-projects:read
      summary: List phases for a project
      description: Returns every phase in sequence order, no pagination.
      operationId: listProjectPhases
      tags: [Projects]
      responses:
        "200":
          description: Phase list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *ProjectPhase
                      total:
                        type: integer
                        description: Number of rows returned (no pagination)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Create a phase
      description: Appends a phase to the project (sequence auto-assigned when omitted). Refused with 409 when the project is archived.
      operationId: createProjectPhase
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string}
                description: {type: string, nullable: true}
                sequence: {type: integer}
                status:
                  type: string
                  enum: [pending, active, completed, skipped]
                  default: pending
                gates_next:
                  type: boolean
                  default: false
                start_date: {type: string, format: date-time, nullable: true}
                target_end_date: {type: string, format: date-time, nullable: true}
            example:
              name: "Deployment"
              gates_next: true
      responses:
        "201":
          description: Phase created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProjectPhase
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Project is archived (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/phases/reorder:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Reorder all phases
      description: |
        Renumbers every phase's sequence to match the order of
        `phase_ids`. The list must be an exact permutation of the
        project's phase IDs — duplicates, missing phases, or unknown IDs
        are rejected with 400. Refused with 409 when the project is
        archived. Returns the full re-sequenced phase list.
      operationId: reorderProjectPhases
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phase_ids]
              properties:
                phase_ids:
                  type: array
                  description: Every phase ID exactly once, in the desired order
                  items: {type: string, format: uuid}
            example:
              phase_ids:
                - "8d0f2c4e-6a7b-8c9d-0e1f-2a3b4c5d6e7f"
                - "7c9e1b3d-5f6a-7b8c-9d0e-1f2a3b4c5d6e"
      responses:
        "200":
          description: Re-sequenced phase list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *ProjectPhase
                      total: {type: integer}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Project is archived (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/phases/{phase_id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: phase_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Update a phase
      description: Partial update with tri-state JSON semantics on nullable fields (absent = unchanged, null = clear, value = set).
      operationId: updateProjectPhase
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                description: {type: string, nullable: true}
                sequence: {type: integer}
                status:
                  type: string
                  enum: [pending, active, completed, skipped]
                gates_next: {type: boolean}
                start_date: {type: string, format: date-time, nullable: true}
                target_end_date: {type: string, format: date-time, nullable: true}
                actual_end_date: {type: string, format: date-time, nullable: true}
            example:
              status: completed
              actual_end_date: "2026-06-12T00:00:00Z"
      responses:
        "200":
          description: Updated phase
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProjectPhase
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Delete a phase
      description: Hard delete. Refused with 409 when it is the project's last phase (every project keeps at least one). Phase-level milestones are demoted to project-level rather than deleted.
      operationId: deleteProjectPhase
      tags: [Projects]
      responses:
        "204":
          description: Phase deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Cannot delete the last phase (CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/risks:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-projects:read
      summary: List RAID entries for a project
      description: Returns every risk/assumption/issue/decision entry for the project, no pagination.
      operationId: listProjectRisks
      tags: [Projects]
      responses:
        "200":
          description: RAID list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &ProjectRisk
                          type: object
                          description: |
                            One RAID register entry. Severity is
                            likelihood x impact on a 1-5 PMI matrix,
                            computed client-side. Status moves freely
                            between open / mitigated / closed / accepted.
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            project_id: {type: string, format: uuid}
                            kind:
                              type: string
                              enum: [risk, assumption, issue, decision]
                            title: {type: string}
                            description: {type: string, nullable: true}
                            likelihood:
                              type: integer
                              minimum: 1
                              maximum: 5
                              description: "1-5 PMI scale, default 3"
                            impact:
                              type: integer
                              minimum: 1
                              maximum: 5
                              description: "1-5 PMI scale, default 3"
                            owner_user_id: {type: string, format: uuid, nullable: true}
                            mitigation: {type: string, nullable: true}
                            status:
                              type: string
                              enum: [open, mitigated, closed, accepted]
                            properties:
                              type: object
                              description: Free-form JSON property bag
                            archived_at: {type: string, format: date-time, nullable: true}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                            created_by: {type: string, format: uuid, nullable: true}
                      total:
                        type: integer
                        description: Number of rows returned (no pagination)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Create a RAID entry
      operationId: createProjectRisk
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                kind:
                  type: string
                  enum: [risk, assumption, issue, decision]
                  default: risk
                title: {type: string}
                description: {type: string, nullable: true}
                likelihood:
                  type: integer
                  minimum: 1
                  maximum: 5
                  default: 3
                impact:
                  type: integer
                  minimum: 1
                  maximum: 5
                  default: 3
                owner_user_id: {type: string, format: uuid, nullable: true}
                mitigation: {type: string, nullable: true}
                status:
                  type: string
                  enum: [open, mitigated, closed, accepted]
                  default: open
            example:
              kind: risk
              title: "Legacy server may not survive the migration window"
              likelihood: 3
              impact: 4
              mitigation: "Full image backup before cutover"
      responses:
        "201":
          description: RAID entry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProjectRisk
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/risks/{risk_id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: risk_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Update a RAID entry
      description: Partial update with tri-state JSON semantics on nullable fields (absent = unchanged, null = clear, value = set).
      operationId: updateProjectRisk
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind:
                  type: string
                  enum: [risk, assumption, issue, decision]
                title: {type: string}
                description: {type: string, nullable: true}
                likelihood: {type: integer, minimum: 1, maximum: 5}
                impact: {type: integer, minimum: 1, maximum: 5}
                owner_user_id: {type: string, format: uuid, nullable: true}
                mitigation: {type: string, nullable: true}
                status:
                  type: string
                  enum: [open, mitigated, closed, accepted]
            example:
              status: mitigated
      responses:
        "200":
          description: Updated RAID entry
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ProjectRisk
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Delete a RAID entry
      operationId: deleteProjectRisk
      tags: [Projects]
      responses:
        "204":
          description: RAID entry deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/projects/{id}/shift-tasks:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-projects:write
      summary: Bulk-shift task due dates
      description: |
        Shifts the resolution due date of the listed project tasks by
        `delta_days` (negative shifts left). Tasks without a due date
        are skipped silently — `shifted_count` reports how many rows
        actually moved. `delta_days` must be non-zero.
      operationId: shiftProjectTasks
      tags: [Projects]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ticket_ids, delta_days]
              properties:
                ticket_ids:
                  type: array
                  items: {type: string, format: uuid}
                delta_days:
                  type: integer
                  description: Days to shift; negative moves earlier. Must be non-zero.
            example:
              ticket_ids:
                - "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
                - "1c7a2b0d-3e75-4a8f-0c2b-8e7a4b3c2d12"
              delta_days: 2
      responses:
        "200":
          description: Shift result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      shifted_count:
                        type: integer
                        description: Rows actually shifted (tasks without a due date are skipped)
                      delta_days: {type: integer}
              example:
                data:
                  shifted_count: 2
                  delta_days: 2
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-approval-chains:
    get:
      x-stability: beta
      x-required-scope: psa-quotes:read
      summary: List quote approval chains
      description: |
        Returns every approval chain configured for the organisation
        (active and inactive), without their steps — fetch a chain by id
        for the step list. Unpaginated. At most one chain is `is_default`;
        the active default chain is the one used when a quote over the
        approval threshold is submitted.
      operationId: listQuoteApprovalChains
      tags: [Quote Approval]
      responses:
        "200":
          description: Approval chain list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &QuoteApprovalChain
                      type: object
                      description: One row from the quote_approval_chains table
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        name: {type: string}
                        description: {type: string}
                        is_active: {type: boolean}
                        is_default:
                          type: boolean
                          description: The active default chain is used on submit-for-approval when the quote exceeds the org threshold
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - &QuoteApprovalChainExample
                    id: "3f9d2c1a-8b7e-4d6f-a5c4-1e2f3a4b5c6d"
                    org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                    name: "Large deals sign-off"
                    description: "Ops manager then MD for anything over the threshold"
                    is_active: true
                    is_default: true
                    created_at: "2026-05-02T10:00:00Z"
                    updated_at: "2026-05-02T10:00:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Create a quote approval chain
      description: |
        Creates an approval chain. `name` is required. Marking a chain
        `is_default: true` clears the flag on any other chain. Requires
        the quote-approve permission (not just edit) so editors cannot
        reshape the approval flow.
      operationId: createQuoteApprovalChain
      tags: [Quote Approval]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string}
                description: {type: string}
                is_active: {type: boolean}
                is_default: {type: boolean}
            example:
              name: "Large deals sign-off"
              description: "Ops manager then MD for anything over the threshold"
              is_active: true
              is_default: true
      responses:
        "201":
          description: Chain created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteApprovalChain
              example:
                data: *QuoteApprovalChainExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-approval-chains/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-quotes:read
      summary: Get an approval chain with its steps
      operationId: getQuoteApprovalChain
      tags: [Quote Approval]
      responses:
        "200":
          description: Chain with ordered steps
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      chain: *QuoteApprovalChain
                      steps:
                        type: array
                        items: &QuoteApprovalChainStep
                          type: object
                          description: One row from the quote_approval_chain_steps table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            chain_id: {type: string, format: uuid}
                            sequence:
                              type: integer
                              description: Approval order; steps run lowest sequence first
                            approver_user_id: {type: string, format: uuid}
                            label: {type: string}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
              example:
                data:
                  chain: *QuoteApprovalChainExample
                  steps:
                    - &QuoteApprovalChainStepExample
                      id: "5a6b7c8d-9e0f-1a2b-3c4d-5e6f7a8b9c0d"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      chain_id: "3f9d2c1a-8b7e-4d6f-a5c4-1e2f3a4b5c6d"
                      sequence: 1
                      approver_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      label: "Ops manager"
                      created_at: "2026-05-02T10:01:00Z"
                      updated_at: "2026-05-02T10:01:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Update an approval chain
      description: Sparse update; absent fields are left unchanged.
      operationId: updateQuoteApprovalChain
      tags: [Quote Approval]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                description: {type: string}
                is_active: {type: boolean}
                is_default: {type: boolean}
            example:
              is_active: false
      responses:
        "200":
          description: Updated chain
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteApprovalChain
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Delete an approval chain
      operationId: deleteQuoteApprovalChain
      tags: [Quote Approval]
      responses:
        "204":
          description: Chain deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-approval-chains/{id}/steps:
    parameters:
      - name: id
        in: path
        required: true
        description: Chain id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Add a step to an approval chain
      operationId: createQuoteApprovalChainStep
      tags: [Quote Approval]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                sequence: {type: integer}
                approver_user_id: {type: string, format: uuid}
                label: {type: string}
            example:
              sequence: 2
              approver_user_id: "2e4f6a8b-0c1d-2e3f-4a5b-6c7d8e9f0a1b"
              label: "Managing director"
      responses:
        "201":
          description: Step created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteApprovalChainStep
              example:
                data: *QuoteApprovalChainStepExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-approval-chains/{id}/steps/{step_id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Chain id (scope segment only — the step is addressed by step_id)
        schema:
          type: string
          format: uuid
      - name: step_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Update an approval chain step
      description: Sparse update of sequence, approver or label. Returns no body.
      operationId: updateQuoteApprovalChainStep
      tags: [Quote Approval]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                sequence: {type: integer}
                approver_user_id: {type: string, format: uuid}
                label: {type: string}
            example:
              label: "Finance director"
      responses:
        "204":
          description: Step updated
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Delete an approval chain step
      operationId: deleteQuoteApprovalChainStep
      tags: [Quote Approval]
      responses:
        "204":
          description: Step deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-settings:
    get:
      x-stability: beta
      x-required-scope: psa-quotes:read
      summary: Get org-level quote settings
      description: |
        Returns the organisation's quote defaults. The row is created on
        first read if absent, so this never 404s.
      operationId: getQuoteSettings
      tags: [Quotes]
      responses:
        "200":
          description: Quote settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &QuoteSettings
                    type: object
                    description: One row from the org_quote_settings table
                    properties:
                      org_id: {type: string, format: uuid}
                      approval_threshold_cents:
                        type: integer
                        format: int64
                        nullable: true
                        description: Quotes whose total exceeds this (in cents) require approval on submit; null disables the approval gate
                      default_expiry_days: {type: integer, nullable: true}
                      default_terms_text: {type: string}
                      default_acceptance_text: {type: string}
                      default_owner_user_id: {type: string, format: uuid, nullable: true}
                      default_email_subject: {type: string}
                      default_email_body: {type: string}
                      default_reminder_days_before:
                        type: integer
                        nullable: true
                        description: Expiry-reminder window in days; null or 0 disables. Not currently settable via this endpoint (read-only here).
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
              example:
                data: &QuoteSettingsExample
                  org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                  approval_threshold_cents: 500000
                  default_expiry_days: 30
                  default_terms_text: "Payment due within 14 days of acceptance."
                  default_acceptance_text: "By signing you accept the terms above."
                  default_owner_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                  default_email_subject: "Your quote from Acme Managed Services"
                  default_email_body: "Hi — your quote is ready to review online."
                  default_reminder_days_before: 7
                  created_at: "2026-05-02T10:00:00Z"
                  updated_at: "2026-06-01T09:30:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Update org-level quote settings
      description: |
        Sparse update. Nullable fields (`approval_threshold_cents`,
        `default_expiry_days`, `default_owner_user_id`) distinguish
        "absent = unchanged" from "null = clear". Deliberately gated on
        the quote-approve permission so a user with mere edit rights
        cannot lower the approval threshold to bypass the approval flow.
        `default_reminder_days_before` cannot be set through this
        endpoint (the handler does not wire it).
      operationId: updateQuoteSettings
      tags: [Quotes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                approval_threshold_cents:
                  type: integer
                  format: int64
                  nullable: true
                  description: Money in cents; null clears the threshold (disables the approval gate)
                default_expiry_days: {type: integer, nullable: true}
                default_terms_text: {type: string}
                default_acceptance_text: {type: string}
                default_owner_user_id: {type: string, format: uuid, nullable: true}
                default_email_subject: {type: string}
                default_email_body: {type: string}
            example:
              approval_threshold_cents: 500000
              default_expiry_days: 30
      responses:
        "200":
          description: Updated settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteSettings
              example:
                data: *QuoteSettingsExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-templates:
    get:
      x-stability: beta
      x-required-scope: psa-quote-templates:read
      summary: List quote templates
      description: |
        Returns active templates by default; pass `include_inactive=true`
        for the full set. Header rows only (no sections/items) and
        unpaginated.
      operationId: listQuoteTemplates
      tags: [Quote Templates]
      parameters:
        - name: include_inactive
          in: query
          description: Include templates with is_active=false
          schema: {type: boolean}
      responses:
        "200":
          description: Template list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &QuoteTemplate
                      type: object
                      description: One row from the quote_templates table
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        name: {type: string}
                        description: {type: string}
                        default_terms_text: {type: string}
                        default_customer_notes: {type: string}
                        default_currency_code:
                          type: string
                          description: ISO 4217 code copied onto instantiated quotes
                        default_expiry_days: {type: integer, nullable: true}
                        creates_project: {type: boolean}
                        project_template_id: {type: string, format: uuid, nullable: true}
                        is_active: {type: boolean}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
                        created_by: {type: string, format: uuid, nullable: true}
              example:
                data:
                  - &QuoteTemplateExample
                    id: "7b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e"
                    org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                    name: "New starter bundle"
                    description: "Laptop + M365 licence + onboarding labour"
                    default_terms_text: "Payment due within 14 days of acceptance."
                    default_customer_notes: "Hardware lead time is 3-5 working days."
                    default_currency_code: "GBP"
                    default_expiry_days: 30
                    creates_project: false
                    project_template_id: null
                    is_active: true
                    created_at: "2026-05-10T14:00:00Z"
                    updated_at: "2026-05-10T14:00:00Z"
                    created_by: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-quote-templates:write
      summary: Create a quote template
      description: "`name` is required; everything else defaults."
      operationId: createQuoteTemplate
      tags: [Quote Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string}
                description: {type: string}
                default_terms_text: {type: string}
                default_customer_notes: {type: string}
                default_currency_code: {type: string}
                default_expiry_days: {type: integer, nullable: true}
                creates_project: {type: boolean}
                project_template_id: {type: string, format: uuid, nullable: true}
                is_active: {type: boolean}
            example:
              name: "New starter bundle"
              description: "Laptop + M365 licence + onboarding labour"
              default_currency_code: "GBP"
              default_expiry_days: 30
      responses:
        "201":
          description: Template created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteTemplate
              example:
                data: *QuoteTemplateExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-templates/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-quote-templates:read
      summary: Get a quote template with sections and items
      operationId: getQuoteTemplate
      tags: [Quote Templates]
      responses:
        "200":
          description: Template detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      template: *QuoteTemplate
                      sections:
                        type: array
                        items: &QuoteTemplateSection
                          type: object
                          description: One row from the quote_template_sections table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            template_id: {type: string, format: uuid}
                            title: {type: string}
                            description: {type: string}
                            sequence: {type: integer}
                            show_subtotal: {type: boolean}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      items:
                        type: array
                        items: &QuoteTemplateItem
                          type: object
                          description: One row from the quote_template_items table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            template_id: {type: string, format: uuid}
                            section_id: {type: string, format: uuid, nullable: true}
                            sequence: {type: integer}
                            kind:
                              type: string
                              enum: [line, discount]
                            service_id: {type: string, format: uuid, nullable: true}
                            tax_code_id: {type: string, format: uuid, nullable: true}
                            description: {type: string}
                            quantity:
                              type: string
                              description: NUMERIC(12.4) encoded as a string to preserve fractional precision
                            unit_price_cents:
                              type: integer
                              format: int64
                              description: Money in cents
                            discount_pct_bps:
                              type: integer
                              description: Per-line discount in basis points (1000 = 10%)
                            is_optional: {type: boolean}
                            is_optional_selected: {type: boolean}
                            alternate_group_id: {type: string, format: uuid, nullable: true}
                            is_recurring: {type: boolean}
                            recurring_billing_frequency:
                              type: string
                              enum: [one_off, weekly, biweekly, monthly, quarterly, semi_annual, annual]
                              description: Required when is_recurring=true; absent otherwise
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
              example:
                data:
                  template: *QuoteTemplateExample
                  sections:
                    - &QuoteTemplateSectionExample
                      id: "8c9d0e1f-2a3b-4c5d-6e7f-8a9b0c1d2e3f"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      template_id: "7b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e"
                      title: "Hardware"
                      description: ""
                      sequence: 1
                      show_subtotal: true
                      created_at: "2026-05-10T14:05:00Z"
                      updated_at: "2026-05-10T14:05:00Z"
                  items:
                    - &QuoteTemplateItemExample
                      id: "9d0e1f2a-3b4c-5d6e-7f8a-9b0c1d2e3f4a"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      template_id: "7b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e"
                      section_id: "8c9d0e1f-2a3b-4c5d-6e7f-8a9b0c1d2e3f"
                      sequence: 1
                      kind: line
                      service_id: null
                      tax_code_id: null
                      description: "Business laptop (14-inch)"
                      quantity: "1.0000"
                      unit_price_cents: 89900
                      discount_pct_bps: 0
                      is_optional: false
                      is_optional_selected: false
                      alternate_group_id: null
                      is_recurring: false
                      created_at: "2026-05-10T14:06:00Z"
                      updated_at: "2026-05-10T14:06:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: beta
      x-required-scope: psa-quote-templates:write
      summary: Update a quote template
      description: |
        Sparse update. An empty or whitespace-only `name` is ignored
        rather than rejected. `default_expiry_days` and
        `project_template_id` accept explicit null to clear.
      operationId: updateQuoteTemplate
      tags: [Quote Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                description: {type: string}
                default_terms_text: {type: string}
                default_customer_notes: {type: string}
                default_currency_code: {type: string}
                default_expiry_days: {type: integer, nullable: true}
                creates_project: {type: boolean}
                project_template_id: {type: string, format: uuid, nullable: true}
                is_active: {type: boolean}
            example:
              is_active: false
      responses:
        "200":
          description: Updated template
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteTemplate
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-quote-templates:write
      summary: Delete a quote template
      description: Hard delete; sections and items cascade.
      operationId: deleteQuoteTemplate
      tags: [Quote Templates]
      responses:
        "204":
          description: Template deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-templates/{id}/instantiate:
    parameters:
      - name: id
        in: path
        required: true
        description: Template id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Instantiate a template into a new draft quote
      description: |
        Clones the template (header defaults + sections + items) into a
        fresh draft quote on the given client. Quote numbers are minted
        as QN-YYYY-NNNN per org per year. Requires the quotes-create
        permission rather than template-manage — the act creates a quote.
        Owner defaults to the caller unless `owner_user_id` is supplied.
      operationId: instantiateQuoteTemplate
      tags: [Quote Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id]
              properties:
                client_id: {type: string, format: uuid}
                contact_id: {type: string, format: uuid, nullable: true}
                owner_user_id: {type: string, format: uuid, nullable: true}
            example:
              client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
              contact_id: "7c2d1e0f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
      responses:
        "201":
          description: Draft quote created from the template
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &Quote
                    type: object
                    description: |
                      One row from the quotes table. Status lifecycle:
                      draft -> pending_approval -> approved -> sent ->
                      viewed -> accepted/declined; sent/viewed can also
                      become needs_info (customer replied), expired
                      (expires_at passed) or withdrawn. A revision marks
                      the old row superseded. accepted, declined,
                      expired, withdrawn and superseded are terminal.
                      All *_cents fields are integer money in the
                      quote's currency.
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      quote_number:
                        type: string
                        description: Per-org human number in the form QN-YYYY-NNNN; shared by all revisions of the same quote
                      client_id: {type: string, format: uuid}
                      site_id: {type: string, format: uuid, nullable: true}
                      contact_id: {type: string, format: uuid, nullable: true}
                      title: {type: string}
                      summary: {type: string}
                      status:
                        type: string
                        enum: [draft, pending_approval, approved, sent, viewed, accepted, declined, expired, withdrawn, superseded, needs_info]
                      subtotal_cents: {type: integer, format: int64}
                      discount_cents: {type: integer, format: int64}
                      tax_cents: {type: integer, format: int64}
                      total_cents: {type: integer, format: int64}
                      currency_code:
                        type: string
                        description: ISO 4217 (3 chars)
                      terms_text: {type: string}
                      customer_notes: {type: string}
                      internal_notes:
                        type: string
                        description: Never shown on the customer portal or PDF
                      expires_at: {type: string, format: date-time, nullable: true}
                      sent_at: {type: string, format: date-time, nullable: true}
                      viewed_first_at: {type: string, format: date-time, nullable: true}
                      viewed_last_at: {type: string, format: date-time, nullable: true}
                      accepted_at: {type: string, format: date-time, nullable: true}
                      declined_at: {type: string, format: date-time, nullable: true}
                      withdrawn_at: {type: string, format: date-time, nullable: true}
                      approval_state:
                        type: string
                        enum: [not_required, pending, approved, rejected]
                      approval_threshold_at_send:
                        type: integer
                        format: int64
                        nullable: true
                        description: Org threshold snapshotted at submit time so later settings changes do not re-gate this quote
                      approval_requested_at: {type: string, format: date-time, nullable: true}
                      approval_requested_by: {type: string, format: uuid, nullable: true}
                      approver_id: {type: string, format: uuid, nullable: true}
                      approval_decided_at: {type: string, format: date-time, nullable: true}
                      approval_rejection_reason: {type: string}
                      creates_project: {type: boolean}
                      project_template_id: {type: string, format: uuid, nullable: true}
                      project_name_override: {type: string}
                      owner_user_id: {type: string, format: uuid, nullable: true}
                      spawned_invoice_id: {type: string, format: uuid, nullable: true}
                      spawned_ticket_id: {type: string, format: uuid, nullable: true}
                      spawned_project_id: {type: string, format: uuid, nullable: true}
                      spawned_contract_id: {type: string, format: uuid, nullable: true}
                      parent_quote_id:
                        type: string
                        format: uuid
                        nullable: true
                        description: Set on revisions; points at the quote this one revised
                      version:
                        type: integer
                        description: 1 for the original; increments per revision
                      expiry_reminder_sent_at: {type: string, format: date-time, nullable: true}
                      org_currency_code:
                        type: string
                        description: Set at send time only when the quote currency differs from the org default
                      fx_rate_to_org_currency:
                        type: string
                        nullable: true
                        description: Decimal FX rate encoded as a string
                      fx_rate_at: {type: string, format: date-time, nullable: true}
                      fx_org_currency_total_cents: {type: integer, format: int64, nullable: true}
                      properties:
                        type: object
                        description: Free-form JSON extension bag
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
                      created_by: {type: string, format: uuid, nullable: true}
              example:
                data: &QuoteExample
                  id: "0c7e2a4b-6d8f-4a1b-9c3d-5e7f9a1b3c5d"
                  org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                  quote_number: "QN-2026-0042"
                  client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
                  site_id: null
                  contact_id: "7c2d1e0f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
                  title: "New starter setup - June intake"
                  summary: "Laptop, licence and onboarding for the new surveyor"
                  status: draft
                  subtotal_cents: 124900
                  discount_cents: 0
                  tax_cents: 24980
                  total_cents: 149880
                  currency_code: "GBP"
                  terms_text: "Payment due within 14 days of acceptance."
                  customer_notes: "Hardware lead time is 3-5 working days."
                  internal_notes: "Margin on the laptop is thin - do not discount further."
                  expires_at: "2026-07-10T23:59:59Z"
                  sent_at: null
                  viewed_first_at: null
                  viewed_last_at: null
                  accepted_at: null
                  declined_at: null
                  withdrawn_at: null
                  approval_state: not_required
                  approval_threshold_at_send: null
                  approval_requested_at: null
                  approval_requested_by: null
                  approver_id: null
                  approval_decided_at: null
                  approval_rejection_reason: ""
                  creates_project: false
                  project_template_id: null
                  project_name_override: ""
                  owner_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                  spawned_invoice_id: null
                  spawned_ticket_id: null
                  spawned_project_id: null
                  spawned_contract_id: null
                  parent_quote_id: null
                  version: 1
                  expiry_reminder_sent_at: null
                  properties: {}
                  created_at: "2026-06-10T11:00:00Z"
                  updated_at: "2026-06-10T11:00:00Z"
                  created_by: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-templates/{id}/items:
    parameters:
      - name: id
        in: path
        required: true
        description: Template id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quote-templates:write
      summary: Add an item to a quote template
      operationId: createQuoteTemplateItem
      tags: [Quote Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema: &QuoteTemplateItemWrite
              type: object
              properties:
                section_id: {type: string, format: uuid, nullable: true}
                sequence: {type: integer}
                kind:
                  type: string
                  enum: [line, discount]
                service_id: {type: string, format: uuid, nullable: true}
                tax_code_id: {type: string, format: uuid, nullable: true}
                description: {type: string}
                quantity:
                  type: string
                  description: Decimal encoded as a string (e.g. "1.5")
                unit_price_cents:
                  type: integer
                  format: int64
                  description: Money in cents
                discount_pct_bps:
                  type: integer
                  description: Basis points (1000 = 10%)
                is_optional: {type: boolean}
                is_optional_selected: {type: boolean}
                alternate_group_id: {type: string, format: uuid, nullable: true}
            example:
              section_id: "8c9d0e1f-2a3b-4c5d-6e7f-8a9b0c1d2e3f"
              kind: line
              description: "Business laptop (14-inch)"
              quantity: "1"
              unit_price_cents: 89900
      responses:
        "201":
          description: Template item created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteTemplateItem
              example:
                data: *QuoteTemplateItemExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-templates/{id}/items/{item_id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Template id (scope segment only — the item is addressed by item_id)
        schema:
          type: string
          format: uuid
      - name: item_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: beta
      x-required-scope: psa-quote-templates:write
      summary: Update a quote template item
      description: Sparse update; nullable UUID fields accept explicit null to clear. Returns no body.
      operationId: updateQuoteTemplateItem
      tags: [Quote Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema: *QuoteTemplateItemWrite
            example:
              unit_price_cents: 84900
      responses:
        "204":
          description: Template item updated
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-quote-templates:write
      summary: Delete a quote template item
      operationId: deleteQuoteTemplateItem
      tags: [Quote Templates]
      responses:
        "204":
          description: Template item deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-templates/{id}/sections:
    parameters:
      - name: id
        in: path
        required: true
        description: Template id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quote-templates:write
      summary: Add a section to a quote template
      operationId: createQuoteTemplateSection
      tags: [Quote Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema: &QuoteTemplateSectionWrite
              type: object
              properties:
                title: {type: string}
                description: {type: string}
                sequence: {type: integer}
                show_subtotal: {type: boolean}
            example:
              title: "Hardware"
              sequence: 1
              show_subtotal: true
      responses:
        "201":
          description: Template section created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteTemplateSection
              example:
                data: *QuoteTemplateSectionExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quote-templates/{id}/sections/{section_id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Template id (scope segment only — the section is addressed by section_id)
        schema:
          type: string
          format: uuid
      - name: section_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: beta
      x-required-scope: psa-quote-templates:write
      summary: Update a quote template section
      description: Sparse update. Returns no body.
      operationId: updateQuoteTemplateSection
      tags: [Quote Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema: *QuoteTemplateSectionWrite
            example:
              title: "Hardware and peripherals"
      responses:
        "204":
          description: Template section updated
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-quote-templates:write
      summary: Delete a quote template section
      operationId: deleteQuoteTemplateSection
      tags: [Quote Templates]
      responses:
        "204":
          description: Template section deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes:
    get:
      x-stability: beta
      x-required-scope: psa-quotes:read
      summary: List quotes
      description: |
        Returns quote headers with denormalised `client_name` and
        `owner_user_name`, newest first. Uses `limit`/`offset` paging
        (not `page`/`per_page`); the total matching count is returned in
        `meta.total`. Superseded revisions are hidden unless
        `include_all_versions=true` — by default the list shows only the
        current revision per quote number.
      operationId: listQuotes
      tags: [Quotes]
      parameters:
        - name: limit
          in: query
          description: Maximum rows to return
          schema: {type: integer, default: 50}
        - name: offset
          in: query
          description: Rows to skip
          schema: {type: integer, default: 0}
        - name: status
          in: query
          description: Repeatable status filter
          explode: true
          schema:
            type: array
            items:
              type: string
              enum: [draft, pending_approval, approved, sent, viewed, accepted, declined, expired, withdrawn, superseded, needs_info]
        - name: owner_user_id
          in: query
          schema: {type: string, format: uuid}
        - name: client_id
          in: query
          schema: {type: string, format: uuid}
        - name: expiring_within_days
          in: query
          description: Only quotes whose expires_at falls within the next N days
          schema: {type: integer}
        - name: q
          in: query
          description: Free-text search across quote number and title
          schema: {type: string}
        - name: include_all_versions
          in: query
          description: Include superseded revisions
          schema: {type: boolean}
      responses:
        "200":
          description: Quote list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      allOf:
                        - *Quote
                        - type: object
                          properties:
                            client_name: {type: string}
                            owner_user_name: {type: string}
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                        description: Total matching rows (for offset paging)
              example:
                data:
                  - id: "0c7e2a4b-6d8f-4a1b-9c3d-5e7f9a1b3c5d"
                    quote_number: "QN-2026-0042"
                    title: "New starter setup - June intake"
                    status: sent
                    total_cents: 149880
                    currency_code: "GBP"
                    client_name: "Greendale Surveyors Ltd"
                    owner_user_name: "Doug Hall"
                meta:
                  total: 1
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Create a draft quote
      description: |
        Creates a quote in `draft` status. `title` and `client_id` are
        required. The quote number (QN-YYYY-NNNN) is minted server-side.
        Owner defaults to the caller unless `owner_user_id` is supplied.
        Add content via the sections and items sub-resources.
      operationId: createQuote
      tags: [Quotes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, client_id]
              properties:
                title: {type: string}
                summary: {type: string, nullable: true}
                client_id: {type: string, format: uuid}
                site_id: {type: string, format: uuid, nullable: true}
                contact_id: {type: string, format: uuid, nullable: true}
                currency_code:
                  type: string
                  nullable: true
                  description: ISO 4217 (3 chars); defaults to the org currency
                terms_text: {type: string, nullable: true}
                customer_notes: {type: string, nullable: true}
                internal_notes: {type: string, nullable: true}
                expires_at: {type: string, format: date-time, nullable: true}
                creates_project:
                  type: boolean
                  nullable: true
                  description: Spawn a project (optionally from project_template_id) when the quote is accepted
                project_template_id: {type: string, format: uuid, nullable: true}
                project_name_override: {type: string, nullable: true}
                owner_user_id: {type: string, format: uuid, nullable: true}
            example:
              title: "New starter setup - June intake"
              client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
              contact_id: "7c2d1e0f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
              currency_code: "GBP"
              expires_at: "2026-07-10T23:59:59Z"
      responses:
        "201":
          description: Draft quote created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Quote
              example:
                data: *QuoteExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-quotes:read
      summary: Get a quote with sections, items and signature
      description: |
        Returns the quote header plus its sections, items and — once
        accepted — the customer's signature record.
      operationId: getQuote
      tags: [Quotes]
      responses:
        "200":
          description: Quote detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      quote: *Quote
                      sections:
                        type: array
                        items: &QuoteSection
                          type: object
                          description: One row from the quote_sections table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            quote_id: {type: string, format: uuid}
                            title: {type: string}
                            description: {type: string}
                            sequence: {type: integer}
                            show_subtotal: {type: boolean}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      items:
                        type: array
                        items: &QuoteItem
                          type: object
                          description: |
                            One row from the quote_items table. quantity is a
                            NUMERIC(12.4) encoded as a string to preserve
                            fractional precision; all *_cents fields are
                            integer money in the quote currency.
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            quote_id: {type: string, format: uuid}
                            section_id: {type: string, format: uuid, nullable: true}
                            sequence: {type: integer}
                            kind:
                              type: string
                              enum: [line, discount]
                            service_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Catalogue service link; mutually exclusive with product_id
                            product_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Product catalogue link; mutually exclusive with service_id
                            tax_code_id: {type: string, format: uuid, nullable: true}
                            description: {type: string}
                            quantity:
                              type: string
                              description: Decimal encoded as a string (e.g. "1.5000")
                            unit_price_cents: {type: integer, format: int64}
                            subtotal_cents: {type: integer, format: int64}
                            tax_cents: {type: integer, format: int64}
                            total_cents: {type: integer, format: int64}
                            is_optional:
                              type: boolean
                              description: Customer can toggle this line on the portal
                            is_optional_selected: {type: boolean}
                            alternate_group_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Non-null for "pick one of N" alternate groups; exactly one line per group should be selected
                            discount_pct_bps:
                              type: integer
                              description: Per-line discount in basis points (1000 = 10%); applied before tax
                            is_recurring:
                              type: boolean
                              description: Recurring lines spawn contract lines on accept rather than invoice items
                            recurring_billing_frequency:
                              type: string
                              enum: [one_off, weekly, biweekly, monthly, quarterly, semi_annual, annual]
                              description: Required when is_recurring=true; absent otherwise
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      signature:
                        type: object
                        nullable: true
                        description: Immutable accept record; present only once the quote is accepted
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          quote_id: {type: string, format: uuid}
                          signed_name: {type: string}
                          signed_email: {type: string}
                          signed_position: {type: string}
                          accepted_terms_text: {type: string}
                          selected_optional_item_ids:
                            type: array
                            items: {type: string, format: uuid}
                          signed_at: {type: string, format: date-time}
                          ip_address: {type: string, nullable: true}
                          user_agent: {type: string}
              example:
                data:
                  quote: *QuoteExample
                  sections:
                    - &QuoteSectionExample
                      id: "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      quote_id: "0c7e2a4b-6d8f-4a1b-9c3d-5e7f9a1b3c5d"
                      title: "Hardware"
                      description: ""
                      sequence: 1
                      show_subtotal: true
                      created_at: "2026-06-10T11:01:00Z"
                      updated_at: "2026-06-10T11:01:00Z"
                  items:
                    - &QuoteItemExample
                      id: "2f3a4b5c-6d7e-8f9a-0b1c-2d3e4f5a6b7c"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      quote_id: "0c7e2a4b-6d8f-4a1b-9c3d-5e7f9a1b3c5d"
                      section_id: "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b"
                      sequence: 1
                      kind: line
                      service_id: null
                      product_id: "4b5c6d7e-8f9a-0b1c-2d3e-4f5a6b7c8d9e"
                      tax_code_id: "6d7e8f9a-0b1c-2d3e-4f5a-6b7c8d9e0f1a"
                      description: "Business laptop (14-inch)"
                      quantity: "1.0000"
                      unit_price_cents: 89900
                      subtotal_cents: 89900
                      tax_cents: 17980
                      total_cents: 107880
                      is_optional: false
                      is_optional_selected: false
                      alternate_group_id: null
                      discount_pct_bps: 0
                      is_recurring: false
                      created_at: "2026-06-10T11:02:00Z"
                      updated_at: "2026-06-10T11:02:00Z"
                  signature: null
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Update a quote header
      description: |
        Sparse update of the header fields. Only `draft` quotes are
        editable — anything post-send returns 409 INVALID_TRANSITION.
        Nullable UUID/date fields (`site_id`, `contact_id`, `expires_at`,
        `project_template_id`, `owner_user_id`) distinguish "absent =
        unchanged" from "null = clear".
      operationId: updateQuote
      tags: [Quotes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: {type: string}
                summary: {type: string}
                client_id: {type: string, format: uuid}
                site_id: {type: string, format: uuid, nullable: true}
                contact_id: {type: string, format: uuid, nullable: true}
                currency_code: {type: string}
                terms_text: {type: string}
                customer_notes: {type: string}
                internal_notes: {type: string}
                expires_at: {type: string, format: date-time, nullable: true}
                creates_project: {type: boolean}
                project_template_id: {type: string, format: uuid, nullable: true}
                project_name_override: {type: string}
                owner_user_id: {type: string, format: uuid, nullable: true}
            example:
              title: "New starter setup - June intake (rev)"
              expires_at: "2026-07-31T23:59:59Z"
      responses:
        "200":
          description: Updated quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Quote
              example:
                data: *QuoteExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in draft status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Delete a draft quote
      description: |
        Hard delete (sections/items cascade). Only `draft` quotes may be
        deleted — use withdraw for anything already sent so the audit
        trail survives. Non-draft returns 409 INVALID_TRANSITION.
      operationId: deleteQuote
      tags: [Quotes]
      responses:
        "204":
          description: Quote deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in draft status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/approval-steps:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-quotes:read
      summary: List the per-quote approval run state
      description: |
        Returns the chain run rows for this quote (one per chain step,
        snapshotted at submit time), ordered by sequence. Empty for
        quotes that never went through a multi-step chain.
      operationId: listQuoteApprovalSteps
      tags: [Quote Approval]
      responses:
        "200":
          description: Approval step run state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      description: One row from the quote_approvals table
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        quote_id: {type: string, format: uuid}
                        chain_id: {type: string, format: uuid}
                        chain_name: {type: string}
                        step_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Originating chain step; null if the step was deleted after submit
                        sequence: {type: integer}
                        approver_user_id: {type: string, format: uuid}
                        label: {type: string}
                        status:
                          type: string
                          enum: [pending, approved, rejected, skipped]
                          description: Steps after a rejected one are marked skipped
                        decided_at: {type: string, format: date-time, nullable: true}
                        rejection_reason: {type: string}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - id: "8f9a0b1c-2d3e-4f5a-6b7c-8d9e0f1a2b3c"
                    org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                    quote_id: "0c7e2a4b-6d8f-4a1b-9c3d-5e7f9a1b3c5d"
                    chain_id: "3f9d2c1a-8b7e-4d6f-a5c4-1e2f3a4b5c6d"
                    chain_name: "Large deals sign-off"
                    step_id: "5a6b7c8d-9e0f-1a2b-3c4d-5e6f7a8b9c0d"
                    sequence: 1
                    approver_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                    label: "Ops manager"
                    status: pending
                    decided_at: null
                    rejection_reason: ""
                    created_at: "2026-06-10T12:00:00Z"
                    updated_at: "2026-06-10T12:00:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/approve:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Approve a quote awaiting approval
      description: |
        Approves a `pending_approval` quote. When the quote is running a
        multi-step chain, this approves the lowest-sequence pending step;
        the quote only flips to `approved` once the final step is
        approved. Without a chain the quote flips to `approved`
        immediately. Requires the quote-approve permission. No request
        body. Returns 409 INVALID_TRANSITION when the quote is not
        pending approval.
      operationId: approveQuote
      tags: [Quote Approval]
      responses:
        "200":
          description: Quote after the approval (status approved, or still pending_approval mid-chain)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Quote
              example:
                data: *QuoteExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not pending approval (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/audit:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-quotes:read
      summary: List the audit trail for a quote
      description: |
        Returns the quote's audit events, newest first, capped at 200
        rows (no paging parameters). Portal actions (viewed, accepted,
        optional toggles) appear with actor_kind=portal; River jobs
        (expiry, reminders) as system.
      operationId: listQuoteAudit
      tags: [Quotes]
      responses:
        "200":
          description: Audit events (newest first, max 200)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      description: One row from the quote_audit_log table
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        quote_id: {type: string, format: uuid}
                        event_type:
                          type: string
                          enum: [created, updated, lines_changed, submitted_for_approval, approved, rejected, sent, resent, viewed, optional_toggled, accepted, declined, withdrawn, expired, invoice_spawned, ticket_spawned, project_spawned, contract_spawned, asset_spawned, revised, expiry_reminder_sent, manual_status_change, reply_received, status_changed]
                        actor_kind:
                          type: string
                          enum: [user, portal, system]
                        actor_user_id: {type: string, format: uuid, nullable: true}
                        payload:
                          type: object
                          description: Event-specific context (free-form JSON)
                        occurred_at: {type: string, format: date-time}
              example:
                data:
                  - id: "0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5e"
                    org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                    quote_id: "0c7e2a4b-6d8f-4a1b-9c3d-5e7f9a1b3c5d"
                    event_type: sent
                    actor_kind: user
                    actor_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                    payload:
                      recipient: "sarah@greendalesurveyors.example"
                    occurred_at: "2026-06-10T12:30:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/clear-needs-info:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Clear the needs_info flag on a quote
      description: |
        Flips `needs_info` back to `viewed` without sending a message —
        for when the operator answered the customer's question
        out-of-band (e.g. a phone call). No request body. Returns 409
        INVALID_TRANSITION unless the quote is currently `needs_info`.
      operationId: clearQuoteNeedsInfo
      tags: [Quotes]
      responses:
        "204":
          description: Status flipped to viewed
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in needs_info status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/items:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Add a line item to a quote
      description: |
        Adds an item to a draft quote and recomputes the quote totals.
        `kind` is `line` or `discount`. `service_id` and `product_id` are
        mutually exclusive; when `product_id` is set, blank description,
        unit price and tax code default from the product. Recurring lines
        (`is_recurring: true`) require `recurring_billing_frequency` and
        spawn contract lines instead of invoice items on accept. Returns
        409 INVALID_TRANSITION when the quote is not draft.
      operationId: createQuoteItem
      tags: [Quotes]
      requestBody:
        required: true
        content:
          application/json:
            schema: &QuoteItemWrite
              type: object
              properties:
                section_id: {type: string, format: uuid, nullable: true}
                sequence: {type: integer}
                kind:
                  type: string
                  enum: [line, discount]
                service_id: {type: string, format: uuid, nullable: true}
                product_id: {type: string, format: uuid, nullable: true}
                tax_code_id: {type: string, format: uuid, nullable: true}
                description: {type: string}
                quantity:
                  type: string
                  description: Decimal encoded as a string (e.g. "1.5"); must be >= 0
                unit_price_cents:
                  type: integer
                  format: int64
                  description: Money in cents
                is_optional: {type: boolean}
                is_optional_selected: {type: boolean}
                alternate_group_id: {type: string, format: uuid, nullable: true}
                discount_pct_bps:
                  type: integer
                  description: Basis points (1000 = 10%)
                is_recurring: {type: boolean}
                recurring_billing_frequency:
                  type: string
                  nullable: true
                  enum: [one_off, weekly, biweekly, monthly, quarterly, semi_annual, annual]
            example:
              section_id: "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b"
              kind: line
              product_id: "4b5c6d7e-8f9a-0b1c-2d3e-4f5a6b7c8d9e"
              description: "Business laptop (14-inch)"
              quantity: "1"
              unit_price_cents: 89900
      responses:
        "201":
          description: Item created (quote totals recomputed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteItem
              example:
                data: *QuoteItemExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in draft status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/items/{item_id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
      - name: item_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Update a quote line item
      description: |
        Sparse update; recomputes quote totals. The item must belong to
        the quote in the URL — an item that exists on a different quote
        returns 403. Nullable UUID fields and
        `recurring_billing_frequency` accept explicit null to clear.
        Returns 409 INVALID_TRANSITION when the quote is not draft.
      operationId: updateQuoteItem
      tags: [Quotes]
      requestBody:
        required: true
        content:
          application/json:
            schema: *QuoteItemWrite
            example:
              quantity: "2"
              discount_pct_bps: 500
      responses:
        "200":
          description: Updated item
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteItem
              example:
                data: *QuoteItemExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in draft status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Delete a quote line item
      description: |
        Removes the item and recomputes quote totals. Same scope check as
        PATCH (403 when the item is on a different quote); 409 when the
        quote is not draft.
      operationId: deleteQuoteItem
      tags: [Quotes]
      responses:
        "204":
          description: Item deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in draft status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/manual-accept:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Manually mark a quote accepted
      description: |
        Runs the full accept workflow with a signature synthesised from
        the calling technician — for when the customer agreed verbally
        and will not use the portal. Spawns the same artefacts as a
        portal accept (invoice, ticket, optionally project/contract).
        Idempotent on already-accepted quotes. Only `sent`, `viewed` or
        `needs_info` quotes qualify (409 otherwise).
      operationId: manualAcceptQuote
      tags: [Quotes]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Free-text justification recorded in the audit log
            example:
              reason: "Customer confirmed by phone 10 June"
      responses:
        "200":
          description: Accept artefacts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      signature_id: {type: string, format: uuid}
                      signed_at: {type: string, format: date-time}
                      invoice_id: {type: string, format: uuid, nullable: true}
                      ticket_id: {type: string, format: uuid}
                      project_id: {type: string, format: uuid, nullable: true}
                      contract_id:
                        type: string
                        format: uuid
                        nullable: true
                        description: Draft contract spawned when the quote had recurring lines
              example:
                data:
                  signature_id: "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d"
                  signed_at: "2026-06-10T15:00:00Z"
                  invoice_id: "5c6d7e8f-9a0b-1c2d-3e4f-5a6b7c8d9e0f"
                  ticket_id: "7e8f9a0b-1c2d-3e4f-5a6b-7c8d9e0f1a2b"
                  project_id: null
                  contract_id: null
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote cannot be accepted from its current status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/manual-decline:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Manually mark a quote declined
      description: |
        Force-flips a `sent`, `viewed` or `needs_info` quote to
        `declined` with an operator-supplied reason. No spawn side
        effects. 409 INVALID_TRANSITION for any other status.
      operationId: manualDeclineQuote
      tags: [Quotes]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Free-text justification recorded in the audit log
            example:
              reason: "Went with another supplier"
      responses:
        "200":
          description: Declined quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Quote
              example:
                data: *QuoteExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote cannot be declined from its current status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/messages:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-quotes:read
      summary: List the reply thread for a quote
      description: |
        Returns the back-and-forth email thread (inbound customer replies
        + outbound staff follow-ups), oldest first, unpaginated. The
        initial "here is your quote" send is not mirrored here.
      operationId: listQuoteMessages
      tags: [Quotes]
      responses:
        "200":
          description: Message thread
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &QuoteMessage
                      type: object
                      description: One row from the quote_messages table
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        quote_id: {type: string, format: uuid}
                        direction:
                          type: string
                          enum: [inbound, outbound]
                        from_address: {type: string}
                        to_address: {type: string}
                        subject: {type: string}
                        body_text: {type: string}
                        body_html: {type: string}
                        message_id:
                          type: string
                          description: RFC 5322 Message-ID
                        in_reply_to: {type: string}
                        references_list:
                          type: array
                          items: {type: string}
                        inbound_auto_submitted: {type: string}
                        inbound_precedence: {type: string}
                        sent_by_user_id: {type: string, format: uuid, nullable: true}
                        received_at: {type: string, format: date-time}
                        created_at: {type: string, format: date-time}
              example:
                data:
                  - &QuoteMessageExample
                    id: "9a0b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d"
                    org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                    quote_id: "0c7e2a4b-6d8f-4a1b-9c3d-5e7f9a1b3c5d"
                    direction: inbound
                    from_address: "sarah@greendalesurveyors.example"
                    to_address: "quotes@mail.acmemanaged.example"
                    subject: "Re: Quote QN-2026-0042"
                    body_text: "Can the laptop come with 32GB instead?"
                    body_html: ""
                    message_id: "<CAF=k9xR@mail.greendalesurveyors.example>"
                    in_reply_to: ""
                    references_list: []
                    inbound_auto_submitted: ""
                    inbound_precedence: ""
                    sent_by_user_id: null
                    received_at: "2026-06-10T13:05:00Z"
                    created_at: "2026-06-10T13:05:02Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Send a reply on the quote thread
      description: |
        Composes and emails an outbound staff reply to the quote contact
        with proper threading headers, mirrors it into the thread, and
        clears `needs_info` back to `viewed` if set. `body_text` or
        `body_html` is required; subject defaults to "Re: Quote
        <number>". The quote must have a contact with an email address
        (400 otherwise). Returns 503 MAIL_UNAVAILABLE when the deployment
        has no mail sender or the tenant has no outbound domain.
      operationId: sendQuoteReply
      tags: [Quotes]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body_text: {type: string}
                body_html: {type: string}
                subject: {type: string}
            example:
              body_text: "Yes - 32GB adds 120 pounds. Want me to revise the quote?"
      responses:
        "201":
          description: Outbound message sent and mirrored
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteMessage
              example:
                data: *QuoteMessageExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Mail not configured for this deployment or tenant (MAIL_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/quotes/{id}/pdf:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    get:
      x-stability: beta
      x-required-scope: psa-quotes:read
      summary: Download the quote PDF
      description: |
        Streams the rendered PDF (operator preview/download). The
        customer-facing copy is served by the public portal instead.
        Served inline via Content-Disposition.
      operationId: getQuotePDF
      tags: [Quotes]
      responses:
        "200":
          description: Rendered PDF
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/reject:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Reject a quote awaiting approval
      description: |
        Rejects a `pending_approval` quote back to `draft` with a reason.
        When the quote is running a multi-step chain, the current step is
        rejected and the remaining steps are marked skipped. Requires the
        quote-approve permission. 409 INVALID_TRANSITION when the quote
        is not pending approval.
      operationId: rejectQuote
      tags: [Quote Approval]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Stored on the quote as approval_rejection_reason
            example:
              reason: "Discount too deep - rework the labour line"
      responses:
        "200":
          description: Quote back in draft with approval_state rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Quote
              example:
                data: *QuoteExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not pending approval (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/revise:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id of the revision source
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Create a new revision of a quote
      description: |
        Clones the quote (header + sections + items) into a fresh `draft`
        with the same quote number and `version` incremented; the source
        is marked `superseded` once the new revision is sent. Only
        `draft`, `approved`, `sent` or `viewed` quotes can be revised —
        accepted and other terminal quotes return 409 (withdraw and raise
        a new quote instead).
      operationId: reviseQuote
      tags: [Quotes]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional free-text revision reason, recorded in the source quote's `revised` audit event payload
            example:
              reason: "Customer asked for 32GB RAM"
      responses:
        "201":
          description: New draft revision
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Quote
              example:
                data: *QuoteExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote cannot be revised from its current status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/sections:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Add a section to a quote
      description: Adds a grouping section to a draft quote. 409 INVALID_TRANSITION when the quote is not draft.
      operationId: createQuoteSection
      tags: [Quotes]
      requestBody:
        required: true
        content:
          application/json:
            schema: &QuoteSectionWrite
              type: object
              properties:
                title: {type: string}
                description: {type: string}
                sequence: {type: integer}
                show_subtotal: {type: boolean}
            example:
              title: "Hardware"
              sequence: 1
              show_subtotal: true
      responses:
        "201":
          description: Section created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteSection
              example:
                data: *QuoteSectionExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in draft status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/sections/{section_id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
      - name: section_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Update a quote section
      description: |
        Sparse update. The section must belong to the quote in the URL —
        a section that exists on a different quote returns 403. 409
        INVALID_TRANSITION when the quote is not draft.
      operationId: updateQuoteSection
      tags: [Quotes]
      requestBody:
        required: true
        content:
          application/json:
            schema: *QuoteSectionWrite
            example:
              title: "Hardware and peripherals"
      responses:
        "200":
          description: Updated section
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *QuoteSection
              example:
                data: *QuoteSectionExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in draft status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Delete a quote section
      description: Items in the section are unassigned rather than deleted. Same 403 scope check and draft-only 409 as PATCH.
      operationId: deleteQuoteSection
      tags: [Quotes]
      responses:
        "204":
          description: Section deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in draft status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/send:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Send (or resend) a quote to the customer
      description: |
        Emails the quote with a magic-link portal URL and flips status to
        `sent`. Allowed from `draft`, `approved`, `sent` and `viewed`
        (resends are audited separately); `pending_approval` returns 409.
        Recipient defaults to the quote contact's email; `recipient_email`
        overrides it (400 when neither resolves). A 30-second resend
        cooldown returns 429 SEND_COOLDOWN. On QuickBooks-connected
        tenants, chargeable free-text lines with no service/product
        mapping block the send with 422 QBO_LINE_MAPPING_REQUIRED. When
        the quote currency differs from the org default an FX rate is
        snapshotted best-effort at send time. Returns 503
        MAIL_UNAVAILABLE when no mail sender is configured.
      operationId: sendQuote
      tags: [Quotes]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                recipient_email:
                  type: string
                  format: email
                  nullable: true
                  description: Overrides the quote contact's email
                subject:
                  type: string
                  nullable: true
                  description: Overrides the org default email subject
                body:
                  type: string
                  nullable: true
                  description: Overrides the org default email body
            example:
              recipient_email: "sarah@greendalesurveyors.example"
      responses:
        "200":
          description: Quote sent; returns what actually went out
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      subject: {type: string}
                      body: {type: string}
                      recipient: {type: string}
                      token:
                        type: string
                        format: uuid
                        description: Portal share token embedded in the emailed link
              example:
                data:
                  subject: "Your quote from Acme Managed Services - QN-2026-0042"
                  body: "Hi Sarah - your quote is ready to review online."
                  recipient: "sarah@greendalesurveyors.example"
                  token: "b1c2d3e4-f5a6-7b8c-9d0e-1f2a3b4c5d6e"
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote awaits approval (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: Lines missing QuickBooks catalogue mapping (QBO_LINE_MAPPING_REQUIRED; details carries the offending lines)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          description: Resend cooldown (SEND_COOLDOWN) or org rate limit
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Mail not configured for this deployment (MAIL_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/quotes/{id}/submit-for-approval:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Submit a draft quote for approval
      description: |
        Moves a `draft` quote into the approval gate. If the org has no
        approval threshold or the quote total is at/under it, the quote
        short-circuits to approval_state `not_required` (no status
        change needed before send). Over the threshold, the quote flips
        to `pending_approval` — via the org's active default chain when
        one exists (multi-step), otherwise the single-step flow. The
        threshold is snapshotted onto the quote. No request body.
      operationId: submitQuoteForApproval
      tags: [Quote Approval]
      responses:
        "200":
          description: Quote after submission
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Quote
              example:
                data: *QuoteExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in a submittable status (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/quotes/{id}/withdraw:
    parameters:
      - name: id
        in: path
        required: true
        description: Quote id
        schema:
          type: string
          format: uuid
    post:
      x-stability: beta
      x-required-scope: psa-quotes:write
      summary: Withdraw a quote
      description: |
        Flips any non-terminal quote to `withdrawn` and revokes its
        portal share tokens so the customer link stops working. Terminal
        quotes (accepted/declined/expired/withdrawn/superseded) return
        409 INVALID_TRANSITION.
      operationId: withdrawQuote
      tags: [Quotes]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Free-text justification recorded in the audit log
            example:
              reason: "Pricing error - replacing with a corrected quote"
      responses:
        "200":
          description: Withdrawn quote
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Quote
              example:
                data: *QuoteExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is already terminal (INVALID_TRANSITION)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/rules:
    get:
      x-stability: internal
      x-required-scope: psa-rules:read
      summary: List automation rules
      description: |
        Returns `{items, total}`. `limit`/`offset` paging; when `limit`
        is omitted ALL rules are returned (no default page size).
      operationId: listTicketRules
      tags: [Ticket Rules]
      parameters:
        - name: enabled
          in: query
          schema: {type: boolean}
        - name: trigger_kind
          in: query
          schema:
            type: string
            enum: [ticket.created, ticket.updated, ticket.commented, ticket.sla_warning, ticket.scheduled_stale, ticket.scheduled_customer_silence]
        - name: q
          in: query
          description: Case-insensitive substring match on the rule name
          schema: {type: string}
        - name: limit
          in: query
          description: Maximum rows to return; omitted = unlimited
          schema: {type: integer}
        - name: offset
          in: query
          schema: {type: integer, default: 0}
      responses:
        "200":
          description: Rule list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &TicketRule
                          type: object
                          description: One row from the ticket_rules table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string}
                            description: {type: string, nullable: true}
                            trigger_kind:
                              type: string
                              enum: [ticket.created, ticket.updated, ticket.commented, ticket.sla_warning, ticket.scheduled_stale, ticket.scheduled_customer_silence]
                            conditions:
                              type: object
                              description: "Condition tree: {op: and|or|not, children: [...]} with leaves {field, operator, value}"
                            actions:
                              type: array
                              description: Ordered action list
                              items:
                                type: object
                                properties:
                                  type: {type: string}
                                  params: {type: object}
                            priority:
                              type: integer
                              description: Evaluation order, lower fires first (default 100)
                            enabled: {type: boolean}
                            cooldown_seconds: {type: integer}
                            max_fires_per_ticket: {type: integer}
                            config:
                              type: object
                              description: JSONB extras (throttle, business hours, gates, round-robin state)
                            created_by: {type: string, format: uuid, nullable: true}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-rules:write
      summary: Create an automation rule
      description: |
        `name`, `trigger_kind` and `actions` are required. `conditions`
        defaults to an always-match AND node, `priority` to 100,
        `enabled` to true. Body capped at 256 KB. Rule names are unique
        per organisation (409 on conflict).
      operationId: createTicketRule
      tags: [Ticket Rules]
      requestBody:
        required: true
        content:
          application/json:
            schema: &TicketRuleCreate
              type: object
              required: [name, trigger_kind, actions]
              properties:
                name: {type: string}
                description: {type: string, nullable: true}
                trigger_kind:
                  type: string
                  enum: [ticket.created, ticket.updated, ticket.commented, ticket.sla_warning, ticket.scheduled_stale, ticket.scheduled_customer_silence]
                conditions:
                  type: object
                  description: Condition tree; omitted = match everything
                actions:
                  type: array
                  items:
                    type: object
                    properties:
                      type: {type: string}
                      params: {type: object}
                priority: {type: integer, default: 100}
                enabled: {type: boolean, default: true}
                cooldown_seconds: {type: integer}
                max_fires_per_ticket: {type: integer}
                config: {type: object}
      responses:
        "201":
          description: Rule created; Location header carries the new resource path
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketRule
        "400":
          description: Validation failure (missing name, invalid trigger_kind, bad condition/action shapes)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: A rule with that name already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/rules/dry-run:
    post:
      x-stability: internal
      x-required-scope: psa-rules:write
      summary: Dry-run a rule definition
      description: |
        Side-effect-free preview: evaluates the supplied (unsaved) rule
        against one ticket (`scope.type=ticket_id`) or the most recent N
        tickets (`scope.type=recent`, `limit` default 50, hard cap 100).
        Actions are decoded with best-effort target checks (does the
        user/category exist, is the webhook URL https) but the dispatcher
        is never invoked. AI actions always report would_execute=false.
      operationId: dryRunTicketRule
      tags: [Ticket Rules]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rule, scope]
              properties:
                rule: *TicketRuleCreate
                scope:
                  type: object
                  required: [type]
                  properties:
                    type:
                      type: string
                      enum: [ticket_id, recent]
                    ticket_id:
                      type: string
                      format: uuid
                      description: Required when type=ticket_id
                    limit:
                      type: integer
                      default: 50
                      maximum: 100
                      description: Candidate count when type=recent
      responses:
        "200":
          description: Preview of matched tickets and per-action outcomes
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      matched:
                        type: array
                        items:
                          type: object
                          properties:
                            ticket_id: {type: string, format: uuid}
                            actions:
                              type: array
                              items:
                                type: object
                                properties:
                                  type: {type: string}
                                  params: {type: object}
                                  would_execute: {type: boolean}
                                  preview_error: {type: string}
                      total_evaluated: {type: integer}
        "400":
          description: Invalid rule definition or scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: scope.ticket_id does not exist in this tenant
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/rules/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: psa-rules:read
      summary: Get a rule
      operationId: getTicketRule
      tags: [Ticket Rules]
      responses:
        "200":
          description: The rule
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketRule
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: internal
      x-required-scope: psa-rules:write
      summary: Update a rule
      description: |
        Sparse update: omitted fields are unchanged. JSONB fields
        (`conditions`, `actions`, `config`) are rewritten only when the
        key is present in the body.
      operationId: updateTicketRule
      tags: [Ticket Rules]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                description: {type: string, nullable: true}
                trigger_kind:
                  type: string
                  enum: [ticket.created, ticket.updated, ticket.commented, ticket.sla_warning, ticket.scheduled_stale, ticket.scheduled_customer_silence]
                conditions: {type: object}
                actions:
                  type: array
                  items:
                    type: object
                    properties:
                      type: {type: string}
                      params: {type: object}
                priority: {type: integer}
                enabled: {type: boolean}
                cooldown_seconds: {type: integer}
                max_fires_per_ticket: {type: integer}
                config: {type: object}
      responses:
        "200":
          description: The updated rule
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketRule
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Name conflict with another rule
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-rules:write
      summary: Delete a rule
      description: Hard delete; the rule's run audit rows are cascaded away.
      operationId: deleteTicketRule
      tags: [Ticket Rules]
      responses:
        "204":
          description: Rule deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/rules/{id}/runs:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: psa-rules:read
      summary: List rule runs (audit history)
      description: |
        Per-rule fire history, newest first, including dry_run=true
        preview rows. Returns `{items, total, limit, offset}`; `limit`
        defaults to 50 and is capped at 200.
      operationId: listTicketRuleRuns
      tags: [Ticket Rules]
      parameters:
        - name: limit
          in: query
          schema: {type: integer, default: 50, maximum: 200}
        - name: offset
          in: query
          schema: {type: integer, default: 0}
      responses:
        "200":
          description: Run history
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          description: One row from ticket_rule_runs
                          properties:
                            id: {type: string, format: uuid}
                            rule_id: {type: string, format: uuid}
                            ticket_id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            fired_at: {type: string, format: date-time}
                            matched: {type: boolean}
                            actions_executed:
                              type: array
                              items:
                                type: object
                                properties:
                                  type: {type: string}
                                  result: {type: object}
                                  error: {type: string}
                                  skipped: {type: boolean}
                            error_message: {type: string, nullable: true}
                            dry_run: {type: boolean}
                      total: {type: integer}
                      limit: {type: integer}
                      offset: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/saved_filters:
    get:
      x-stability: internal
      x-required-scope: psa-saved-filters:write
      summary: List saved filters
      description: |
        Returns saved filters visible to the calling user — org-shared
        filters plus the caller's private ones. entity_type defaults to
        'ticket' and is currently the only accepted value (400 otherwise).
      operationId: listSavedFilters
      tags: [Saved Filters]
      parameters:
        - name: entity_type
          in: query
          schema:
            type: string
            enum: [ticket]
            default: ticket
      responses:
        "200":
          description: Saved filter list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &SavedFilter
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            user_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: null = org-shared filter
                            entity_type: {type: string, enum: [ticket]}
                            name: {type: string}
                            query:
                              type: object
                              description: Persisted TicketsPage query state (free-form JSON)
                            is_shared: {type: boolean}
                            created_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-saved-filters:write
      summary: Create a saved filter
      description: Name must be unique (409 DUPLICATE). entity_type must be 'ticket' when given.
      operationId: createSavedFilter
      tags: [Saved Filters]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, nullable: true}
                query:
                  type: object
                  description: Query state blob to persist
                is_shared: {type: boolean, nullable: true}
                entity_type:
                  type: string
                  enum: [ticket]
                  nullable: true
      responses:
        "201":
          description: Saved filter created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SavedFilter
        "400":
          description: Validation failure (e.g. entity_type not 'ticket')
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Name already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/saved_filters/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: internal
      x-required-scope: psa-saved-filters:write
      summary: Update a saved filter
      description: |
        Private filters may only be modified by their owner (or a
        superuser); shared filters (user_id null) only by a superuser
        (403 otherwise).
      operationId: updateSavedFilter
      tags: [Saved Filters]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, nullable: true}
                query:
                  type: object
                  description: Query state blob to persist
                is_shared: {type: boolean, nullable: true}
      responses:
        "200":
          description: Updated saved filter
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SavedFilter
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Name already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-saved-filters:write
      summary: Delete a saved filter
      description: |
        Same authorisation rule as update — owner (or superuser) for
        private filters, superuser only for shared ones.
      operationId: deleteSavedFilter
      tags: [Saved Filters]
      responses:
        "204":
          description: Saved filter deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/ai/features:
    patch:
      x-stability: internal
      x-required-scope: psa-tickets:write
      summary: Toggle AI features
      description: |
        Merge-patches organizations.ai_features_enabled. Body is a map of
        feature name to boolean; any subset of the canonical features is
        accepted and unknown keys are rejected with a structured 422.
        Returns the full merged map.
      operationId: patchAiFeaturesEnabled
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: "Keys: sentiment, summary, draft_reply, classify, script_generate"
              additionalProperties: {type: boolean}
      responses:
        "200":
          description: Merged feature map
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: {type: boolean}
        "400":
          description: Malformed/empty body or non-boolean value
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "422":
          description: Unknown feature key (non-standard envelope; error object carries a `feature` field)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code: {type: string}
                      feature: {type: string}
                      message: {type: string}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/ai/rate-limits:
    patch:
      x-stability: internal
      x-required-scope: psa-tickets:write
      summary: Patch AI per-feature daily rate limits
      description: |
        Merge-patches organizations.ai_rate_limits. Body is a map of
        feature name to daily call cap (1–10000). Unknown keys and
        out-of-range values are rejected with a structured 422. Returns
        the full merged map.
      operationId: patchAiRateLimits
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: "Keys: sentiment, summary, draft_reply, classify, script_generate"
              additionalProperties:
                type: integer
                minimum: 1
                maximum: 10000
      responses:
        "200":
          description: Merged rate-limit map
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    additionalProperties: {type: integer}
        "400":
          description: Malformed/empty body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "422":
          description: Unknown feature key or out-of-range limit (non-standard envelope; error object carries a `feature` field)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: object
                    properties:
                      code: {type: string}
                      feature: {type: string}
                      message: {type: string}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/ai/usage:
    get:
      x-stability: internal
      x-required-scope: psa-tickets:write
      summary: List AI usage metering rows
      description: |
        Per-day, per-feature token/cost rows for the admin AI dashboard.
        `from`/`to` are inclusive YYYY-MM-DD calendar dates (UTC);
        defaults are the trailing 30 days. Payload lives at `data.items`
        / `data.total_cost_cents` in the standard envelope.
      operationId: listAiUsage
      tags: [PSA Settings]
      parameters:
        - name: from
          in: query
          description: Inclusive start day, YYYY-MM-DD (default today - 29 days)
          schema: {type: string, format: date}
        - name: to
          in: query
          description: Inclusive end day, YYYY-MM-DD (default today)
          schema: {type: string, format: date}
      responses:
        "200":
          description: Usage rows in the window
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            day:
                              type: string
                              format: date
                            feature:
                              type: string
                              enum: [sentiment, summary, draft_reply, classify, script_generate]
                            call_count: {type: integer}
                            prompt_tokens: {type: integer, format: int64}
                            completion_tokens: {type: integer, format: int64}
                            estimated_cost_cents: {type: integer, format: int64}
                      total_cost_cents: {type: integer, format: int64}
        "400":
          description: Unparseable or out-of-order dates
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/categories:
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: List ticket categories
      description: |
        Without `parent_id` returns root categories only; with it,
        the direct children of that parent. Returns `{items, total}`
        where total is simply the item count — no pagination.
      operationId: listTicketCategories
      tags: [PSA Settings]
      parameters:
        - name: parent_id
          in: query
          description: List direct children of this category instead of roots
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Category list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &TicketCategory
                          type: object
                          description: One node in the 3-level category tree
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            parent_id: {type: string, format: uuid, nullable: true}
                            name: {type: string}
                            path:
                              type: string
                              description: ltree path built from slugified names (e.g. Network.Firewall.Rules)
                            depth: {type: integer}
                            active: {type: boolean}
                            sort_order: {type: integer}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Create a ticket category
      description: |
        Tree depth is capped at 3 levels (409 DEPTH_EXCEEDED). Sibling
        names that slugify to the same ltree label collide (409
        CATEGORY_NAME_COLLISION). An unknown `parent_id` is a 400.
      operationId: createTicketCategory
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                parent_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Omit or null for a root category
                name: {type: string}
                sort_order: {type: integer}
                active: {type: boolean, default: true}
      responses:
        "201":
          description: Category created; Location header carries the new resource path
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketCategory
        "400":
          description: Missing name or unknown parent_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Depth limit exceeded or sibling name collision
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/categories/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Update a ticket category
      description: Renaming rewrites the ltree path for the whole subtree.
      operationId: updateTicketCategory
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                sort_order: {type: integer}
                active: {type: boolean}
      responses:
        "200":
          description: The updated category
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketCategory
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Depth limit exceeded or sibling name collision
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Delete a ticket category
      operationId: deleteTicketCategory
      tags: [PSA Settings]
      responses:
        "204":
          description: Category deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Category (or its subtree) still referenced by tickets
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/categories/{id}/subtree:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: List a category subtree
      description: Returns the node itself plus every descendant, ordered by ltree path.
      operationId: listTicketCategorySubtree
      tags: [PSA Settings]
      responses:
        "200":
          description: Subtree nodes
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *TicketCategory
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/priority-matrix:
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: Get the 5x5 priority matrix
      description: |
        Always returns all 25 (impact, urgency) cells. Cells without a
        stored override are synthesised from the algorithmic derivation
        and marked source=derived.
      operationId: getPriorityMatrix
      tags: [PSA Settings]
      responses:
        "200":
          description: The complete grid
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            impact_level: {type: integer, minimum: 1, maximum: 5}
                            urgency_level: {type: integer, minimum: 1, maximum: 5}
                            priority:
                              type: string
                              enum: [low, medium, high, urgent]
                            source:
                              type: string
                              enum: [stored, derived]
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/priority-matrix/reset:
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Reset the priority matrix
      description: Deletes every stored cell; subsequent reads synthesise all 25 cells from the algorithmic fallback.
      operationId: resetPriorityMatrix
      tags: [PSA Settings]
      responses:
        "200":
          description: Matrix reset
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [reset]
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/priority-matrix/{impact}/{urgency}:
    parameters:
      - name: impact
        in: path
        required: true
        schema: {type: integer, minimum: 1, maximum: 5}
      - name: urgency
        in: path
        required: true
        schema: {type: integer, minimum: 1, maximum: 5}
    put:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Upsert a priority matrix cell
      operationId: upsertPriorityMatrixCell
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [priority]
              properties:
                priority:
                  type: string
                  enum: [low, medium, high, urgent]
      responses:
        "200":
          description: The stored cell row
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      impact_level: {type: integer}
                      urgency_level: {type: integer}
                      priority:
                        type: string
                        enum: [low, medium, high, urgent]
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
        "400":
          description: impact/urgency outside 1..5 or invalid priority
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Delete a priority matrix cell
      description: Removes the stored override; reads fall back to the algorithmic value for this cell.
      operationId: deletePriorityMatrixCell
      tags: [PSA Settings]
      responses:
        "204":
          description: Cell deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/sla-calendars:
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: List SLA calendars
      description: Returns `{items, total}` with total = item count; no pagination or filters.
      operationId: listSlaCalendars
      tags: [PSA Settings]
      responses:
        "200":
          description: Calendar list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &SLACalendar
                          type: object
                          description: One row from the sla_calendars table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string}
                            timezone:
                              type: string
                              description: IANA tz name (e.g. Europe/London)
                            business_hours:
                              type: object
                              description: "Weekday-name keys mapping to time ranges, e.g. {monday: [{start: '09:00', end: '17:30'}]}"
                              additionalProperties:
                                type: array
                                items:
                                  type: object
                                  properties:
                                    start: {type: string}
                                    end: {type: string}
                            holidays:
                              type: array
                              items:
                                type: object
                                properties:
                                  date:
                                    type: string
                                    format: date
                                  name: {type: string}
                            is_24x7: {type: boolean}
                            active: {type: boolean}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Create an SLA calendar
      description: Timezone and business_hours shapes are validated; failures return 400.
      operationId: createSlaCalendar
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, timezone]
              properties:
                name: {type: string}
                timezone: {type: string}
                business_hours:
                  type: object
                  additionalProperties:
                    type: array
                    items:
                      type: object
                      properties:
                        start: {type: string}
                        end: {type: string}
                holidays:
                  type: array
                  items:
                    type: object
                    properties:
                      date:
                        type: string
                        format: date
                      name: {type: string}
                is_24x7: {type: boolean}
                active: {type: boolean}
      responses:
        "201":
          description: Calendar created; Location header carries the new resource path
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SLACalendar
        "400":
          description: Invalid timezone or business_hours shape
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/sla-calendars/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: Get an SLA calendar
      operationId: getSlaCalendar
      tags: [PSA Settings]
      responses:
        "200":
          description: The calendar
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SLACalendar
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Update an SLA calendar
      description: Sparse update; omitted fields are unchanged.
      operationId: updateSlaCalendar
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                timezone: {type: string}
                business_hours: {type: object}
                holidays: {type: array, items: {type: object}}
                is_24x7: {type: boolean}
                active: {type: boolean}
      responses:
        "200":
          description: The updated calendar
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SLACalendar
        "400":
          description: Invalid timezone or business_hours shape
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Delete an SLA calendar
      operationId: deleteSlaCalendar
      tags: [PSA Settings]
      responses:
        "204":
          description: Calendar deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Calendar still referenced by SLA plans
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/sla-plans:
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: List SLA plans
      description: Returns `{items, total}` with total = item count; filterable, not paginated.
      operationId: listSlaPlans
      tags: [PSA Settings]
      parameters:
        - name: active
          in: query
          description: true restricts to active plans; false/absent returns all
          schema: {type: boolean}
        - name: priority
          in: query
          schema:
            type: string
            enum: [low, medium, high, urgent]
        - name: ticket_type
          in: query
          description: "Exact match including the wildcard value '*'"
          schema: {type: string}
      responses:
        "200":
          description: Plan list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &SLAPlan
                          type: object
                          description: One row from the sla_plans table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string}
                            priority:
                              type: string
                              enum: [low, medium, high, urgent]
                            ticket_type:
                              type: string
                              description: "One of incident|service_request|problem|change|task or '*' (any type)"
                            client_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: null = applies to any client
                            calendar_id: {type: string, format: uuid}
                            response_time_minutes: {type: integer}
                            resolution_time_minutes: {type: integer}
                            active: {type: boolean}
                            conditions:
                              type: object
                              description: Reserved JSONB extension point
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Create an SLA plan
      description: |
        `calendar_id` must reference an existing calendar in this org
        (400 otherwise). Omitting `client_id` (or sending null) creates
        an any-client wildcard plan.
      operationId: createSlaPlan
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, priority, ticket_type, calendar_id, response_time_minutes, resolution_time_minutes]
              properties:
                name: {type: string}
                priority:
                  type: string
                  enum: [low, medium, high, urgent]
                ticket_type:
                  type: string
                  description: "incident|service_request|problem|change|task or '*'"
                client_id:
                  type: string
                  format: uuid
                  nullable: true
                calendar_id: {type: string, format: uuid}
                response_time_minutes: {type: integer}
                resolution_time_minutes: {type: integer}
                active: {type: boolean, default: true}
                conditions: {type: object}
      responses:
        "201":
          description: Plan created; Location header carries the new resource path
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SLAPlan
        "400":
          description: Validation failure (missing fields, bad priority/type, unknown calendar_id)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/sla-plans/resolve:
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: Resolve the SLA plan for a hypothetical ticket
      description: |
        Returns the most-specific active plan matching (ticket_type,
        priority, client_id) using the specificity lattice exact+exact >
        exact+wildcard > wildcard+exact > wildcard+wildcard. 404 when no
        plan matches.
      operationId: resolveSlaPlan
      tags: [PSA Settings]
      parameters:
        - name: ticket_type
          in: query
          required: true
          schema:
            type: string
            enum: [incident, service_request, problem, change, task]
        - name: priority
          in: query
          required: true
          schema:
            type: string
            enum: [low, medium, high, urgent]
        - name: client_id
          in: query
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: The matching plan
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SLAPlan
        "400":
          description: Missing ticket_type/priority or invalid client_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: No plan matches the supplied tuple
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/sla-plans/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: Get an SLA plan
      operationId: getSlaPlan
      tags: [PSA Settings]
      responses:
        "200":
          description: The plan
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SLAPlan
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Update an SLA plan
      description: |
        Sparse update. `client_id` distinguishes three states: absent =
        unchanged, explicit null = clear to any-client wildcard, a UUID
        = pin to that client.
      operationId: updateSlaPlan
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                priority:
                  type: string
                  enum: [low, medium, high, urgent]
                ticket_type: {type: string}
                client_id:
                  type: string
                  format: uuid
                  nullable: true
                calendar_id: {type: string, format: uuid}
                response_time_minutes: {type: integer}
                resolution_time_minutes: {type: integer}
                active: {type: boolean}
                conditions: {type: object}
      responses:
        "200":
          description: The updated plan
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SLAPlan
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Delete an SLA plan
      description: Soft delete (plan deactivated, not removed).
      operationId: deleteSlaPlan
      tags: [PSA Settings]
      responses:
        "204":
          description: Plan deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/ticket-type-forms:
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: List per-type ticket forms (merged view)
      description: |
        One row per ticket type: the tenant override when present,
        otherwise the platform default, each tagged with `source`.
        Returns `{items, total}`; no pagination (max 5 rows).
      operationId: listTicketTypeForms
      tags: [PSA Settings]
      responses:
        "200":
          description: Merged form registry
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          allOf:
                            - &TicketTypeForm
                              type: object
                              description: One row from the ticket_type_forms table
                              properties:
                                id: {type: string, format: uuid}
                                org_id:
                                  type: string
                                  format: uuid
                                  nullable: true
                                  description: null = platform default row
                                ticket_type:
                                  type: string
                                  enum: [incident, service_request, problem, change, task]
                                fields:
                                  type: array
                                  description: Ordered dynamic field definitions
                                  items:
                                    type: object
                                    properties:
                                      key: {type: string}
                                      label_i18n: {type: string}
                                      type: {type: string}
                                      required: {type: boolean}
                                      order: {type: integer}
                                      options:
                                        type: array
                                        nullable: true
                                        items:
                                          type: object
                                          properties:
                                            value: {type: string}
                                            label_i18n: {type: string}
                                active: {type: boolean}
                                created_at: {type: string, format: date-time}
                                updated_at: {type: string, format: date-time}
                            - type: object
                              properties:
                                source:
                                  type: string
                                  enum: [tenant, default]
                                  description: Whether the row is a tenant override or the platform default
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/ticket-type-forms/defaults:
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: List platform-default ticket forms
      description: Just the platform-default rows (org_id null), without the `source` marker.
      operationId: listTicketTypeFormDefaults
      tags: [PSA Settings]
      responses:
        "200":
          description: Platform defaults
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *TicketTypeForm
                      total: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/ticket-type-forms/{ticket_type}:
    parameters:
      - name: ticket_type
        in: path
        required: true
        schema:
          type: string
          enum: [incident, service_request, problem, change, task]
    put:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Upsert a tenant form override
      description: Creates or replaces the tenant override for this ticket type.
      operationId: upsertTicketTypeForm
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fields]
              properties:
                fields:
                  type: array
                  items:
                    type: object
                    properties:
                      key: {type: string}
                      label_i18n: {type: string}
                      type: {type: string}
                      required: {type: boolean}
                      order: {type: integer}
                      options:
                        type: array
                        nullable: true
                        items:
                          type: object
                          properties:
                            value: {type: string}
                            label_i18n: {type: string}
      responses:
        "200":
          description: The stored tenant override
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketTypeForm
        "400":
          description: Missing/invalid fields payload or invalid ticket_type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-settings:write
      summary: Reset a form to the platform default
      description: Deactivates the tenant override so reads fall back to the platform default. 204 even when no override existed.
      operationId: resetTicketTypeForm
      tags: [PSA Settings]
      responses:
        "204":
          description: Override reset
        "400":
          description: Invalid ticket_type
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/settings/tickets:
    get:
      x-stability: internal
      x-required-scope: psa-tickets:read
      summary: Get org-wide ticket settings
      description: |
        Auto-archive age, RESOLVED per-type ticket-number prefixes
        (defaults overlaid with tenant overrides), and the number the
        next created ticket will receive.
      operationId: getPsaTicketSettings
      tags: [PSA Settings]
      responses:
        "200":
          description: Current settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &PsaTicketSettings
                    type: object
                    properties:
                      auto_archive_email_tickets_after_days:
                        type: integer
                        description: 0 = auto-archive disabled
                      ticket_number_prefixes:
                        type: object
                        description: Resolved map of ticket_type to prefix (e.g. INC, SR)
                        additionalProperties: {type: string}
                      next_ticket_number:
                        type: integer
                        format: int64
                        nullable: true
                        description: The number the next created ticket will receive
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update org-wide ticket settings
      description: |
        `auto_archive_email_tickets_after_days` accepts 0..3650.
        Prefix values are upper-cased and must match `^[A-Z0-9]{1,5}$`;
        blank values fall back to the built-in default for that type.
        `next_ticket_number` is optional and FORWARD-ONLY: any value not
        strictly greater than the current next number is rejected with
        400 COUNTER_NOT_FORWARD. Body capped at 4 KB. Returns the new
        resolved state.
      operationId: updatePsaTicketSettings
      tags: [PSA Settings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                auto_archive_email_tickets_after_days:
                  type: integer
                  minimum: 0
                  maximum: 3650
                ticket_number_prefixes:
                  type: object
                  description: Only known ticket types accepted as keys
                  additionalProperties: {type: string}
                next_ticket_number:
                  type: integer
                  format: int64
                  minimum: 1
                  description: Optional forward-only counter jump
      responses:
        "200":
          description: The saved settings (resolved prefixes + live next number)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PsaTicketSettings
        "400":
          description: Out-of-range value, bad prefix, unknown ticket type, or non-forward counter jump
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/status:
    get:
      x-stability: internal
      x-required-scope: psa-settings:read
      summary: Get PSA module status for the current tenant
      description: |
        Single round-trip bootstrap for the PSA UI — tier, Entra
        connection state, locale resolution and key permission flags.
      operationId: getPsaStatus
      tags: [PSA Settings]
      responses:
        "200":
          description: PSA status
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      tier:
                        type: object
                        properties:
                          tier: {type: string}
                          is_grandfathered: {type: boolean}
                          billing_status: {type: string}
                          label_key: {type: string}
                      entra:
                        type: object
                        properties:
                          connected: {type: boolean}
                          last_sync_at: {type: string, format: date-time, nullable: true}
                          last_sync_status: {type: string}
                          last_sync_count: {type: integer, nullable: true}
                          last_sync_error: {type: string}
                      locale:
                        type: object
                        properties:
                          user: {type: string}
                          org_default: {type: string}
                          resolved: {type: string}
                      permissions:
                        type: object
                        properties:
                          psa_settings_view: {type: boolean}
                          psa_settings_edit: {type: boolean}
                          psa_tickets_view: {type: boolean}
                          psa_portal_view: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /psa/suppliers:
    get:
      x-stability: beta
      x-required-scope: psa-products:read
      summary: List suppliers
      description: |
        Returns all suppliers for the organisation sorted by name
        ascending, as a plain array in `data`. No pagination at all —
        supplier counts are expected to stay small. `active=true`
        filters to active suppliers only.
      operationId: listSuppliers
      tags: [Suppliers]
      parameters:
        - name: active
          in: query
          description: Pass true to return active suppliers only
          schema: {type: boolean}
      responses:
        "200":
          description: Supplier list (unpaginated)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &Supplier
                      type: object
                      description: One row from the suppliers table
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        name: {type: string}
                        description: {type: string}
                        feed_type:
                          type: string
                          enum: [manual, csv, api, ftp, edi]
                          description: Ingest mechanism; csv is the self-service path, api/ftp/edi are direct integrations
                        contact_email:
                          type: string
                          description: Stored verbatim, not validated
                        website: {type: string}
                        default_markup_bps:
                          type: integer
                          nullable: true
                          description: "Default markup in basis points (2000 = 20%) suggested when promoting a feed item; null = no default"
                        is_active: {type: boolean}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - &SupplierExample
                    id: "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e"
                    org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                    name: "Northgate Distribution Ltd"
                    description: "Primary distributor for client hardware"
                    feed_type: csv
                    contact_email: "accounts@northgatedist.example"
                    website: "https://www.northgatedist.example"
                    default_markup_bps: 2000
                    is_active: true
                    created_at: "2026-05-18T11:00:00Z"
                    updated_at: "2026-06-09T16:45:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Create a supplier
      description: |
        Creates a supplier. `name` is required and unique per
        organisation (409 DUPLICATE otherwise). `feed_type` defaults to
        `manual`.
      operationId: createSupplier
      tags: [Suppliers]
      requestBody:
        required: true
        content:
          application/json:
            schema: &SupplierUpsert
              type: object
              required: [name]
              properties:
                name: {type: string}
                description:
                  type: string
                  nullable: true
                  description: "On PATCH an empty string clears the value"
                feed_type:
                  type: string
                  nullable: true
                  enum: [manual, csv, api, ftp, edi]
                contact_email:
                  type: string
                  nullable: true
                  description: "On PATCH an empty string clears the value"
                website:
                  type: string
                  nullable: true
                  description: "On PATCH an empty string clears the value"
                default_markup_bps: {type: integer, nullable: true}
                is_active: {type: boolean, nullable: true}
            example:
              name: "Northgate Distribution Ltd"
              description: "Primary distributor for client hardware"
              feed_type: csv
              contact_email: "accounts@northgatedist.example"
              website: "https://www.northgatedist.example"
              default_markup_bps: 2000
      responses:
        "201":
          description: Supplier created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Supplier
              example:
                data: *SupplierExample
        "400":
          description: Missing name or invalid body (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: A supplier with this name already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/suppliers/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: beta
      x-required-scope: psa-products:read
      summary: Get a supplier
      operationId: getSupplier
      tags: [Suppliers]
      responses:
        "200":
          description: Supplier
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Supplier
              example:
                data: *SupplierExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Update a supplier
      description: |
        Sparse update. Omitted fields (and an omitted/empty `name`) are
        left unchanged. Empty strings clear `description`,
        `contact_email`, and `website`.
      operationId: updateSupplier
      tags: [Suppliers]
      requestBody:
        required: true
        content:
          application/json:
            schema: *SupplierUpsert
            example:
              default_markup_bps: 1500
              is_active: true
      responses:
        "200":
          description: Updated supplier
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Supplier
              example:
                data: *SupplierExample
        "400":
          description: Invalid body (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: A supplier with this name already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Delete a supplier
      description: |
        Hard delete. Cascades to the supplier's feed items, CSV mapping
        presets, and product supplier-SKU links. Curated products are
        untouched.
      operationId: deleteSupplier
      tags: [Suppliers]
      responses:
        "204":
          description: Deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/suppliers/{id}/feed:
    parameters:
      - name: id
        in: path
        required: true
        description: Supplier id
        schema: {type: string, format: uuid}
    get:
      x-stability: beta
      x-required-scope: psa-products:read
      summary: List a supplier's feed items
      description: |
        Returns raw feed items for one supplier, most recently seen
        first. Uses `limit`/`offset` paging and returns a plain array
        in `data` — no total count. An unknown supplier id returns an
        empty array, not a 404.
      operationId: listSupplierFeedItems
      tags: [Suppliers]
      parameters:
        - name: q
          in: query
          description: Substring match on feed item name
          schema: {type: string}
        - name: in_stock
          in: query
          description: Pass true to return in-stock items only
          schema: {type: boolean}
        - name: limit
          in: query
          description: "Max rows, 1-500; out-of-range values fall back to the default"
          schema: {type: integer, default: 100, maximum: 500}
        - name: offset
          in: query
          description: Rows to skip
          schema: {type: integer, default: 0}
      responses:
        "200":
          description: Feed item list (no total count)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: *FeedItem
              example:
                data:
                  - *FeedItemExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/suppliers/{id}/feed/ingest:
    parameters:
      - name: id
        in: path
        required: true
        description: Supplier id (must match the one used at preview time)
        schema: {type: string, format: uuid}
    post:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Ingest a previewed supplier CSV
      description: |
        Step two of the CSV import flow. Applies the chosen column
        `mapping` to the rows cached by a prior `/feed/preview` call
        (referenced by `token`) and upserts them into the supplier's
        feed on (supplier, SKU). The mapping must include
        `supplier_sku` and `name`; other canonical keys (`description`,
        `manufacturer`, `category`, `cost`, `currency`, `in_stock`,
        `stock_quantity`) are optional. `cost` columns are parsed as
        major units (e.g. 1234.56) and converted to pence. Rows with a
        blank SKU or name are skipped; unparseable costs are reported
        in `errors` without failing the ingest. Preview tokens expire
        after roughly 30 minutes — an expired or mismatched token
        returns 400. Optionally saves the mapping as a preset via
        `save_as_preset_name`.
      operationId: ingestSupplierCsv
      tags: [Suppliers]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, mapping]
              properties:
                token:
                  type: string
                  description: Token returned by the preview call
                mapping:
                  type: object
                  additionalProperties: {type: string}
                  description: Canonical field name -> source CSV column header
                save_as_preset_name:
                  type: string
                  description: When set, saves this mapping as a named preset on the supplier
            example:
              token: "0e9d8c7b-6a5f-4e3d-2c1b-0a9f8e7d6c5b"
              mapping:
                supplier_sku: "Stock Code"
                name: "Description"
                cost: "Trade Price"
                in_stock: "In Stock"
                manufacturer: "Brand"
              save_as_preset_name: "Northgate weekly price file"
      responses:
        "200":
          description: Ingest counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ingest_id:
                        type: string
                        format: uuid
                        description: Correlates the upserted feed rows from this import
                      total_rows: {type: integer}
                      inserted_new: {type: integer}
                      updated_rows: {type: integer}
                      skipped_rows:
                        type: integer
                        description: Rows skipped for a blank SKU or name
                      errors:
                        type: array
                        items: {type: string}
                        description: Per-row parse warnings (e.g. unparseable cost); omitted when empty
              example:
                data:
                  ingest_id: "5a4b3c2d-1e0f-9a8b-7c6d-5e4f3a2b1c0d"
                  total_rows: 1240
                  inserted_new: 312
                  updated_rows: 920
                  skipped_rows: 8
                  errors:
                    - "row 57 cost \"POA\": not a number"
        "400":
          description: Expired/mismatched token, mapping missing supplier_sku/name, or ingest failure (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/suppliers/{id}/feed/mappings:
    parameters:
      - name: id
        in: path
        required: true
        description: Supplier id
        schema: {type: string, format: uuid}
    get:
      x-stability: beta
      x-required-scope: psa-products:read
      summary: List a supplier's CSV mapping presets
      description: |
        Returns saved column-mapping presets for one supplier, default
        first then by name. Plain array in `data`, no pagination. An
        unknown supplier id returns an empty array.
      operationId: listSupplierCsvMappings
      tags: [Suppliers]
      responses:
        "200":
          description: Mapping presets
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &CSVMapping
                      type: object
                      description: One saved CSV column-mapping preset (supplier_csv_mappings row)
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        supplier_id: {type: string, format: uuid}
                        name: {type: string}
                        is_default:
                          type: boolean
                          description: At most one default per supplier
                        mapping:
                          type: object
                          additionalProperties: {type: string}
                          description: Canonical field name -> source CSV column header
                        delimiter:
                          type: string
                          description: Single character; Northgate files use a semicolon
                        has_header: {type: boolean}
                        skip_rows: {type: integer}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - &CSVMappingExample
                    id: "9c8b7a6f-5e4d-3c2b-1a0f-9e8d7c6b5a4f"
                    org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                    supplier_id: "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e"
                    name: "Northgate weekly price file"
                    is_default: true
                    mapping:
                      supplier_sku: "Stock Code"
                      name: "Description"
                      cost: "Trade Price"
                      in_stock: "In Stock"
                      manufacturer: "Brand"
                    delimiter: ";"
                    has_header: true
                    skip_rows: 0
                    created_at: "2026-05-20T09:05:00Z"
                    updated_at: "2026-06-10T06:00:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Save a CSV mapping preset
      description: |
        Saves (or replaces) a mapping preset. Upserts on
        (supplier, name): posting an existing preset name overwrites
        that preset and still returns 201. `name` and a non-empty
        `mapping` are required. Setting `is_default` clears the default
        flag from the supplier's other presets. `delimiter` defaults to
        a comma when omitted.
      operationId: saveSupplierCsvMapping
      tags: [Suppliers]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, mapping]
              properties:
                name: {type: string}
                is_default: {type: boolean, default: false}
                mapping:
                  type: object
                  additionalProperties: {type: string}
                  description: Canonical field name -> source CSV column header
                delimiter:
                  type: string
                  description: "Single character, default comma"
                has_header: {type: boolean}
                skip_rows: {type: integer}
            example:
              name: "Northgate weekly price file"
              is_default: true
              mapping:
                supplier_sku: "Stock Code"
                name: "Description"
                cost: "Trade Price"
              delimiter: ";"
              has_header: true
              skip_rows: 0
      responses:
        "201":
          description: Preset saved (created or overwritten by name)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *CSVMapping
              example:
                data: *CSVMappingExample
        "400":
          description: Missing name or mapping (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/suppliers/{id}/feed/mappings/{mid}:
    parameters:
      - name: id
        in: path
        required: true
        description: Supplier id (not validated against the mapping)
        schema: {type: string, format: uuid}
      - name: mid
        in: path
        required: true
        description: Mapping preset id
        schema: {type: string, format: uuid}
    delete:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Delete a CSV mapping preset
      operationId: deleteSupplierCsvMapping
      tags: [Suppliers]
      responses:
        "204":
          description: Deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/suppliers/{id}/feed/preview:
    parameters:
      - name: id
        in: path
        required: true
        description: Supplier id (bound to the returned token)
        schema: {type: string, format: uuid}
    post:
      x-stability: beta
      x-required-scope: psa-products:write
      summary: Preview a supplier CSV upload
      description: |
        Step one of the CSV import flow. Upload the price file as
        multipart form data (max 25 MiB); the server parses it with the
        supplied hints and returns the detected headers, up to 20
        sample rows, the total row count, and a `token`. The full parse
        is cached in memory for roughly 30 minutes — pass the token to
        `/feed/ingest` to commit. The cache is per-process: preview and
        ingest must hit the same instance.
      operationId: previewSupplierCsv
      tags: [Suppliers]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The CSV price file (must be non-empty)
                delimiter:
                  type: string
                  description: "First character is used; default comma"
                has_header:
                  type: string
                  description: "true or false; default true"
                skip_rows:
                  type: string
                  description: Leading rows to discard before parsing; default 0
      responses:
        "200":
          description: Parsed preview plus ingest token
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      headers:
                        type: array
                        items: {type: string}
                        description: Detected column headers (synthesised when has_header=false)
                      sample_rows:
                        type: array
                        items:
                          type: object
                          additionalProperties: {type: string}
                        description: Up to 20 parsed rows keyed by header
                      total_rows: {type: integer}
                      delimiter: {type: string}
                      has_header: {type: boolean}
                      skip_rows: {type: integer}
                      token:
                        type: string
                        description: Pass to /feed/ingest within ~30 minutes
              example:
                data:
                  headers: ["Stock Code", "Description", "Brand", "Trade Price", "In Stock"]
                  sample_rows:
                    - "Stock Code": "WC123456"
                      "Description": "Dell Latitude 5550 i7 16GB 512GB W11P"
                      "Brand": "Dell"
                      "Trade Price": "876.50"
                      "In Stock": "Y"
                  total_rows: 1240
                  delimiter: ";"
                  has_header: true
                  skip_rows: 0
                  token: "0e9d8c7b-6a5f-4e3d-2c1b-0a9f8e7d6c5b"
        "400":
          description: "Missing/empty file, unparseable multipart body, or CSV parse error (VALIDATION_ERROR)"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/ticket_templates:
    get:
      x-stability: internal
      x-required-scope: psa-ticket-templates:read
      summary: List ticket templates
      operationId: listTicketTemplates
      tags: [Ticket Templates]
      responses:
        "200":
          description: Ticket template list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &TicketTemplate
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string}
                            category: {type: string, nullable: true}
                            subject_template: {type: string}
                            description_template: {type: string, nullable: true}
                            default_priority_level:
                              type: integer
                              minimum: 1
                              maximum: 5
                              nullable: true
                            default_type:
                              type: string
                              enum: [incident, service_request, problem, change, task]
                              nullable: true
                            default_category_id: {type: string, format: uuid, nullable: true}
                            properties:
                              type: object
                              description: Default per-type form values (free-form JSON)
                            created_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: internal
      x-required-scope: psa-ticket-templates:write
      summary: Create a ticket template
      description: |
        Name is required and must be unique (409 DUPLICATE).
        default_priority_level, when set, must be 1..5.
      operationId: createTicketTemplate
      tags: [Ticket Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema: &TicketTemplateRequest
              type: object
              properties:
                name: {type: string, nullable: true}
                category: {type: string, nullable: true}
                subject_template: {type: string, nullable: true}
                description_template: {type: string, nullable: true}
                default_priority_level:
                  type: integer
                  minimum: 1
                  maximum: 5
                  nullable: true
                default_type:
                  type: string
                  enum: [incident, service_request, problem, change, task]
                  nullable: true
                default_category_id: {type: string, format: uuid, nullable: true}
                properties:
                  type: object
                  description: Default per-type form values
      responses:
        "201":
          description: Ticket template created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketTemplate
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Name already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/ticket_templates/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: internal
      x-required-scope: psa-ticket-templates:write
      summary: Update a ticket template
      operationId: updateTicketTemplate
      tags: [Ticket Templates]
      requestBody:
        required: true
        content:
          application/json:
            schema: *TicketTemplateRequest
      responses:
        "200":
          description: Updated ticket template
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketTemplate
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Name already exists (DUPLICATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: internal
      x-required-scope: psa-ticket-templates:write
      summary: Delete a ticket template
      operationId: deleteTicketTemplate
      tags: [Ticket Templates]
      responses:
        "204":
          description: Ticket template deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets:
    get:
      x-stability: stable
      x-required-scope: psa-tickets:read
      summary: List tickets
      description: |
        Returns tickets for the organisation, newest-updated first. Uses
        `limit`/`offset` paging (not `page`/`per_page`) and returns
        `{items, total}` rather than `meta.pagination`. Merged-away and
        archived tickets are hidden by default; opt in with
        `include_merged`, `include_archived`, or restrict to archived
        rows only with `archived_only`. `assigned_to` and
        `unassigned=true` are mutually exclusive (400).
      operationId: listTickets
      tags: [Tickets]
      parameters:
        - name: limit
          in: query
          description: Maximum rows to return
          schema: {type: integer, default: 50}
        - name: offset
          in: query
          description: Rows to skip
          schema: {type: integer, default: 0}
        - name: status
          in: query
          schema:
            type: string
            enum: [new, open, pending, waiting_customer, resolved, closed, cancelled]
        - name: priority
          in: query
          schema:
            type: string
            enum: [low, medium, high, urgent]
        - name: client_id
          in: query
          schema: {type: string, format: uuid}
        - name: requester_contact_id
          in: query
          schema: {type: string, format: uuid}
        - name: assigned_to
          in: query
          schema: {type: string, format: uuid}
        - name: source_type
          in: query
          schema:
            type: string
            enum: [manual, email, alert, api, portal]
        - name: type
          in: query
          schema:
            type: string
            enum: [incident, service_request, problem, change, task]
        - name: category_id
          in: query
          schema: {type: string, format: uuid}
        - name: project_id
          in: query
          description: Pin the list to one project's tasks
          schema: {type: string, format: uuid}
        - name: project_scope
          in: query
          description: Include/exclude project tasks in the global queue
          schema:
            type: string
            enum: [service, project, all]
        - name: q
          in: query
          description: Free-text search across subject, description, and comment bodies
          schema: {type: string}
        - name: unassigned
          in: query
          description: Only tickets with no assignee (mutually exclusive with assigned_to)
          schema: {type: boolean}
        - name: needs_triage
          in: query
          description: true = triage queue only; false = explicitly-triaged only; absent = no filter
          schema: {type: boolean}
        - name: include_merged
          in: query
          description: Include tickets that have been merged into another ticket
          schema: {type: boolean}
        - name: include_archived
          in: query
          description: Mix archived tickets into the result
          schema: {type: boolean}
        - name: archived_only
          in: query
          description: Only archived tickets (takes precedence over include_archived)
          schema: {type: boolean}
      responses:
        "200":
          description: Ticket list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &Ticket
                          type: object
                          description: One row from the tickets table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            ticket_number:
                              type: integer
                              format: int64
                              description: Per-org sequential number; shown as e.g. INC-1042 with the type prefix
                            client_id: {type: string, format: uuid, nullable: true}
                            site_id: {type: string, format: uuid, nullable: true}
                            requester_contact_id: {type: string, format: uuid, nullable: true}
                            requester_user_id: {type: string, format: uuid, nullable: true}
                            assigned_to: {type: string, format: uuid, nullable: true}
                            assigned_team_id: {type: string, format: uuid, nullable: true}
                            subject: {type: string}
                            description: {type: string}
                            status:
                              type: string
                              enum: [new, open, pending, waiting_customer, resolved, closed, cancelled]
                            impact:
                              type: integer
                              minimum: 1
                              maximum: 5
                              description: ITIL impact, 1 = highest
                            urgency:
                              type: integer
                              minimum: 1
                              maximum: 5
                              description: ITIL urgency, 1 = highest
                            priority_level:
                              type: string
                              enum: [low, medium, high, urgent]
                              description: Derived server-side from impact x urgency; never set directly
                            source_type:
                              type: string
                              enum: [manual, email, alert, api, portal]
                            source_id: {type: string, nullable: true}
                            bundle_id: {type: string, format: uuid, nullable: true}
                            response_due_at: {type: string, format: date-time, nullable: true}
                            resolution_due_at: {type: string, format: date-time, nullable: true}
                            paused_at: {type: string, format: date-time, nullable: true}
                            paused_duration_seconds: {type: integer, format: int64}
                            first_responded_at: {type: string, format: date-time, nullable: true}
                            resolved_at: {type: string, format: date-time, nullable: true}
                            closed_at: {type: string, format: date-time, nullable: true}
                            archived_at: {type: string, format: date-time, nullable: true}
                            properties:
                              type: object
                              description: Per-type dynamic form values (free-form JSON)
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                            needs_triage: {type: boolean}
                            inbound_sender_name: {type: string, nullable: true}
                            inbound_sender_email: {type: string, nullable: true}
                            inbound_auto_submitted: {type: string, nullable: true}
                            inbound_precedence: {type: string, nullable: true}
                            description_html:
                              type: string
                              nullable: true
                              description: Rich HTML body when the ticket originated from inbound email
                            type:
                              type: string
                              enum: [incident, service_request, problem, change, task]
                            category_id: {type: string, format: uuid, nullable: true}
                            sla_plan_id: {type: string, format: uuid, nullable: true}
                            ai_sentiment:
                              nullable: true
                              description: Cached conversation-level AI sentiment rollup (JSON)
                            ai_summary: {type: string, nullable: true}
                            ai_summary_at: {type: string, format: date-time, nullable: true}
                            ai_category_suggestion:
                              nullable: true
                              description: Cached top-3 AI category suggestions (JSON array)
                            ai_draft_citations:
                              nullable: true
                              description: Citation list from the most recent AI draft-reply (JSON)
                            parent_id: {type: string, format: uuid, nullable: true}
                            merged_into_id: {type: string, format: uuid, nullable: true}
                            merged_at: {type: string, format: date-time, nullable: true}
                            estimated_hours: {type: number, nullable: true}
                            project_id: {type: string, format: uuid, nullable: true}
                            phase_id: {type: string, format: uuid, nullable: true}
                            merged_from:
                              type: array
                              nullable: true
                              description: Source tickets merged into this one (GET detail only)
                              items:
                                type: object
                                properties:
                                  id: {type: string, format: uuid}
                                  subject: {type: string}
                            project_name: {type: string, nullable: true, description: List hydration only}
                            project_code: {type: string, nullable: true, description: List hydration only}
                            project_description: {type: string, nullable: true, description: List hydration only}
                            phase_name: {type: string, nullable: true, description: List hydration only}
                            requester_name: {type: string, nullable: true, description: List hydration only}
                            client_name: {type: string, nullable: true, description: List hydration only}
                      total:
                        type: integer
                        description: Total matching rows (for offset paging)
              example:
                data:
                  items:
                    - &TicketExample
                      id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      ticket_number: 1042
                      client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
                      site_id: null
                      requester_contact_id: "7c2d1e0f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
                      requester_user_id: null
                      assigned_to: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      assigned_team_id: null
                      subject: "Outlook keeps prompting for credentials"
                      description: "User at Greendale Surveyors reports repeated O365 password prompts since this morning."
                      status: open
                      impact: 3
                      urgency: 2
                      priority_level: high
                      source_type: email
                      source_id: "<CAF=k9xR@mail.greendalesurveyors.example>"
                      bundle_id: null
                      response_due_at: "2026-06-10T11:00:00Z"
                      resolution_due_at: "2026-06-11T17:00:00Z"
                      paused_at: null
                      paused_duration_seconds: 0
                      first_responded_at: "2026-06-10T09:42:00Z"
                      resolved_at: null
                      closed_at: null
                      properties: {}
                      created_at: "2026-06-10T09:15:23Z"
                      updated_at: "2026-06-10T09:42:00Z"
                      needs_triage: false
                      type: incident
                      category_id: null
                      sla_plan_id: "2e4f6a8b-0c1d-2e3f-4a5b-6c7d8e9f0a1b"
                      requester_name: "Sarah Pemberton"
                      client_name: "Greendale Surveyors Ltd"
                  total: 1
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Create a ticket
      description: |
        Creates a ticket. `priority_level` is derived server-side from
        `impact` x `urgency` (1 = highest, 5 = lowest; both default 3).
        `assigned_team_id` is rejected with 400 until a teams table
        exists. Referenced clients/sites/contacts/users must belong to
        the caller's org (400 INVALID_REFERENCE otherwise).
      operationId: createTicket
      tags: [Tickets]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              properties:
                client_id: {type: string, format: uuid, nullable: true}
                site_id: {type: string, format: uuid, nullable: true}
                requester_contact_id: {type: string, format: uuid, nullable: true}
                requester_user_id: {type: string, format: uuid, nullable: true}
                assigned_to: {type: string, format: uuid, nullable: true}
                assigned_team_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Currently rejected with 400 (no teams table yet)
                subject: {type: string}
                description: {type: string}
                impact: {type: integer, minimum: 1, maximum: 5}
                urgency: {type: integer, minimum: 1, maximum: 5}
                source_type:
                  type: string
                  enum: [manual, email, alert, api, portal]
                  default: manual
                properties:
                  type: object
                  description: Per-type dynamic form values
            example:
              client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
              requester_contact_id: "7c2d1e0f-4a5b-6c7d-8e9f-0a1b2c3d4e5f"
              subject: "Outlook keeps prompting for credentials"
              description: "Repeated O365 password prompts since this morning."
              impact: 3
              urgency: 2
              source_type: api
      responses:
        "201":
          description: Ticket created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Ticket
              example:
                data: *TicketExample
        "400":
          description: Validation failure or cross-org reference (VALIDATION_ERROR / INVALID_REFERENCE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/stream:
    get:
      x-stability: stable
      x-required-scope: psa-tickets:read
      summary: Stream ticket lifecycle events (SSE)
      description: |
        Server-Sent Events stream of ticket lifecycle events
        (created/updated/commented/merged) for the caller's org. Each
        `data:` frame is an invalidation signal only — payload bodies are
        deliberately not forwarded. Heartbeat comments are emitted every
        25s; the connection is hard-capped at 1 hour (EventSource
        auto-reconnects). Returns 503 when NATS is unavailable.
      operationId: streamTicketEvents
      tags: [Tickets]
      responses:
        "200":
          description: SSE stream of ticket event frames
          content:
            text/event-stream:
              schema:
                type: string
                description: |
                  Each data frame is JSON:
                  {event, ticket_id, status, priority_level, occurred_at}
              example: |
                data: {"event":"psa.ticket.updated","ticket_id":"0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01","status":"open","priority_level":"high","occurred_at":"2026-06-10T09:42:00Z"}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/tickets/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-tickets:read
      summary: Get a ticket with comments and attachments
      description: |
        Returns the ticket plus nested comments, attachment metadata, and
        resolved related-entity names. If this ticket has been merged
        into another, the destination ticket's body is transparently
        substituted (the URL stays stable for bookmarks) and the
        destination carries `merged_from`. Opt-in hydration via
        `?include=` CSV: `category_path`, `sla_plan`, `type_form`
        (unknown values ignored).
      operationId: getTicket
      tags: [Tickets]
      parameters:
        - name: include
          in: query
          description: "CSV of opt-in hydrations: category_path, sla_plan, type_form"
          schema: {type: string}
      responses:
        "200":
          description: Ticket detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ticket: *Ticket
                      comments:
                        type: array
                        items: &TicketComment
                          type: object
                          description: One row from the ticket_comments table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            ticket_id: {type: string, format: uuid}
                            author_user_id: {type: string, format: uuid, nullable: true}
                            author_contact_id: {type: string, format: uuid, nullable: true}
                            body: {type: string}
                            body_html: {type: string, nullable: true}
                            is_internal: {type: boolean}
                            source_type:
                              type: string
                              enum: [manual, email, alert, api, portal]
                            source_message_id: {type: string, nullable: true}
                            haraka_mail_log_id: {type: string, nullable: true}
                            delivery_status: {type: string, nullable: true}
                            delivery_diagnostic: {type: string, nullable: true}
                            created_at: {type: string, format: date-time}
                            cc:
                              type: array
                              nullable: true
                              description: Outbound CC recipients
                              items:
                                type: object
                                required: [email]
                                properties:
                                  name: {type: string}
                                  email: {type: string, format: email}
                                  contact_id: {type: string, format: uuid, nullable: true}
                            bcc:
                              type: array
                              nullable: true
                              description: Outbound BCC recipients (visible to author + admins only)
                              items:
                                type: object
                                required: [email]
                                properties:
                                  name: {type: string}
                                  email: {type: string, format: email}
                                  contact_id: {type: string, format: uuid, nullable: true}
                      attachments:
                        type: array
                        items: &TicketAttachment
                          type: object
                          description: Attachment metadata (bytes live in B2; download via the download endpoint)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            ticket_id: {type: string, format: uuid}
                            comment_id: {type: string, format: uuid, nullable: true}
                            uploaded_by_user_id: {type: string, format: uuid, nullable: true}
                            uploaded_by_contact_id: {type: string, format: uuid, nullable: true}
                            filename: {type: string}
                            mime_type: {type: string}
                            size_bytes: {type: integer, format: int64}
                            sha256: {type: string}
                            created_at: {type: string, format: date-time}
                      client_name: {type: string, nullable: true}
                      site_name: {type: string, nullable: true}
                      requester_contact_name: {type: string, nullable: true}
                      requester_contact_email: {type: string, nullable: true}
                      category_path:
                        type: array
                        description: Present only with include=category_path
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            parent_id: {type: string, format: uuid, nullable: true}
                            name: {type: string}
                            path: {type: string, description: "ltree path, e.g. Network.Firewall"}
                            depth: {type: integer}
                            active: {type: boolean}
                            sort_order: {type: integer}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      sla_plan:
                        type: object
                        nullable: true
                        description: Present only with include=sla_plan
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          name: {type: string}
                          priority: {type: string}
                          ticket_type: {type: string, description: "Ticket type or '*' wildcard"}
                          client_id: {type: string, format: uuid, nullable: true}
                          calendar_id: {type: string, format: uuid}
                          response_time_minutes: {type: integer}
                          resolution_time_minutes: {type: integer}
                          active: {type: boolean}
                          conditions: {description: JSON conditions blob}
                          created_at: {type: string, format: date-time}
                          updated_at: {type: string, format: date-time}
                      type_form:
                        type: object
                        nullable: true
                        description: Present only with include=type_form
                        properties:
                          id: {type: string, format: uuid}
                          org_id:
                            type: string
                            format: uuid
                            nullable: true
                            description: null = shared platform default
                          ticket_type:
                            type: string
                            enum: [incident, service_request, problem, change, task]
                          fields: {description: Dynamic field definitions (JSON array)}
                          active: {type: boolean}
                          created_at: {type: string, format: date-time}
                          updated_at: {type: string, format: date-time}
                      requester_reply_email_enabled:
                        type: boolean
                        description: Whether a public reply will be emailed to the requester
              example:
                data:
                  ticket: *TicketExample
                  comments:
                    - id: "3f5a7b9c-1d2e-3f4a-5b6c-7d8e9f0a1b2c"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      ticket_id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
                      author_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      author_contact_id: null
                      body: "Cleared the credential cache and re-registered the device in Entra. Monitoring."
                      is_internal: false
                      source_type: manual
                      source_message_id: null
                      created_at: "2026-06-10T09:42:00Z"
                      cc: []
                      bcc: []
                  attachments: []
                  client_name: "Greendale Surveyors Ltd"
                  site_name: null
                  requester_contact_name: "Sarah Pemberton"
                  requester_contact_email: "sarah@greendalesurveyors.example"
                  requester_reply_email_enabled: true
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    patch:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Partially update a ticket
      description: |
        Patch semantics: omitted fields are unchanged. Status changes are
        rejected with 400 USE_TRANSITION_ENDPOINT — use POST
        /psa/tickets/{id}/transition. `expected_updated_at` opts into
        optimistic locking: echo the `updated_at` you last loaded and a
        concurrent edit returns 409 VERSION_CONFLICT. `assigned_team_id`
        is rejected with 400 (no teams table yet).
      operationId: updateTicket
      tags: [Tickets]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                client_id: {type: string, format: uuid, nullable: true}
                site_id: {type: string, format: uuid, nullable: true}
                requester_contact_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Reassign the requester; clearing to null is not supported
                assigned_to: {type: string, format: uuid, nullable: true}
                assigned_team_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Currently rejected with 400 (no teams table yet)
                subject: {type: string, nullable: true}
                description: {type: string, nullable: true}
                impact: {type: integer, minimum: 1, maximum: 5, nullable: true}
                urgency: {type: integer, minimum: 1, maximum: 5, nullable: true}
                status:
                  type: string
                  nullable: true
                  description: Rejected with 400 USE_TRANSITION_ENDPOINT — use the transition endpoint
                response_due_at: {type: string, format: date-time, nullable: true}
                resolution_due_at: {type: string, format: date-time, nullable: true}
                expected_updated_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: Optimistic-lock token (the updated_at last read)
            example:
              assigned_to: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
              urgency: 1
              expected_updated_at: "2026-06-10T09:42:00Z"
      responses:
        "200":
          description: Updated ticket
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Ticket
              example:
                data: *TicketExample
        "400":
          description: Validation failure, USE_TRANSITION_ENDPOINT, or INVALID_REFERENCE
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Concurrent edit (VERSION_CONFLICT)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Delete a ticket
      description: |
        Hard-deletes the ticket. Refused with 409 TICKET_HAS_BILLED_TIME
        when any time entry on the ticket has been invoiced — void the
        invoice first.
      operationId: deleteTicket
      tags: [Tickets]
      responses:
        "204":
          description: Ticket deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Ticket has invoiced time (TICKET_HAS_BILLED_TIME)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/activity:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-tickets:read
      summary: Get ticket activity (audit trail)
      description: |
        Returns up to 100 audit-log entries for the ticket, newest first.
        No paging.
      operationId: getTicketActivity
      tags: [Tickets]
      responses:
        "200":
          description: Activity entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            user_id: {type: string, format: uuid, nullable: true}
                            action: {type: string}
                            resource_type: {type: string}
                            resource_id: {type: string, format: uuid, nullable: true}
                            changes: {description: Structured change detail (JSON)}
                            created_at: {type: string, format: date-time}
              example:
                data:
                  items:
                    - id: "8a9b0c1d-2e3f-4a5b-6c7d-8e9f0a1b2c3d"
                      user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      action: "ticket_update"
                      resource_type: "ticket"
                      resource_id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
                      changes: {"urgency": {"from": 2, "to": 1}}
                      created_at: "2026-06-10T10:05:11Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/archive:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Archive a ticket
      description: |
        Sets archived_at on the ticket, hiding it from the default list
        view. Idempotent. Orthogonal to status — no status precondition
        for manual archive.
      operationId: archiveTicket
      tags: [Tickets]
      responses:
        "200":
          description: Archived ticket
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Ticket
              example:
                data: *TicketExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/attachments:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-tickets:read
      summary: List ticket attachments
      operationId: listTicketAttachments
      tags: [Tickets]
      responses:
        "200":
          description: Attachment metadata list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *TicketAttachment
              example:
                data:
                  items:
                    - &TicketAttachmentExample
                      id: "6c7d8e9f-0a1b-2c3d-4e5f-6a7b8c9d0e1f"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      ticket_id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
                      comment_id: null
                      uploaded_by_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      uploaded_by_contact_id: null
                      filename: "outlook-credential-prompt.png"
                      mime_type: "image/png"
                      size_bytes: 48211
                      sha256: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
                      created_at: "2026-06-10T09:20:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Upload a ticket attachment
      description: |
        Multipart upload; the file goes to object storage and the
        metadata row is returned. Maximum size 10 MB (413
        ATTACHMENT_TOO_LARGE). Returns 503 STORAGE_UNCONFIGURED when
        attachment storage is not configured.
      operationId: uploadTicketAttachment
      tags: [Tickets]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The attachment (max 10 MB)
            example:
              file: (binary — outlook-credential-prompt.png)
      responses:
        "201":
          description: Attachment stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketAttachment
              example:
                data: *TicketAttachmentExample
        "400":
          description: Missing file field
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "413":
          description: Attachment exceeds 10 MB (ATTACHMENT_TOO_LARGE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Attachment storage not configured (STORAGE_UNCONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/tickets/{id}/attachments/{aid}/download:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: aid
        in: path
        required: true
        description: Attachment ID
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-tickets:read
      summary: Download a ticket attachment
      description: |
        Proxies the attachment bytes through the API (no presigned URLs)
        so every download is authenticated and audit-logged. Response
        carries Content-Disposition and an X-Content-SHA256 header.
      operationId: downloadTicketAttachment
      tags: [Tickets]
      responses:
        "200":
          description: Attachment bytes (Content-Type is the stored MIME type)
          headers:
            Content-Disposition:
              schema: {type: string}
              description: attachment; filename="..."
            X-Content-SHA256:
              schema: {type: string}
              description: SHA-256 of the file content
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
              example: (binary file content)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Attachment storage not configured (STORAGE_UNCONFIGURED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /psa/tickets/{id}/comments:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-tickets:read
      summary: List ticket comments
      operationId: listTicketComments
      tags: [Tickets]
      responses:
        "200":
          description: Comment list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *TicketComment
              example:
                data:
                  items:
                    - id: "3f5a7b9c-1d2e-3f4a-5b6c-7d8e9f0a1b2c"
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      ticket_id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
                      author_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      author_contact_id: null
                      body: "Cleared the credential cache and re-registered the device in Entra. Monitoring."
                      is_internal: false
                      source_type: manual
                      source_message_id: null
                      created_at: "2026-06-10T09:42:00Z"
                      cc: []
                      bcc: []
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Add a ticket comment
      description: |
        Adds a comment. `is_internal=true` keeps it off the client
        portal and suppresses outbound email. Public replies may be
        emailed to the requester (see `requester_reply_email_enabled` on
        the ticket detail). CC/BCC entries require a valid `email`.
        Returns 409 ALREADY_CLOSED on closed tickets.
      operationId: addTicketComment
      tags: [Tickets]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: {type: string}
                body_html: {type: string}
                is_internal: {type: boolean, default: false}
                cc:
                  type: array
                  items:
                    type: object
                    required: [email]
                    properties:
                      name: {type: string}
                      email: {type: string, format: email}
                      contact_id: {type: string, format: uuid, nullable: true}
                bcc:
                  type: array
                  items:
                    type: object
                    required: [email]
                    properties:
                      name: {type: string}
                      email: {type: string, format: email}
                      contact_id: {type: string, format: uuid, nullable: true}
            example:
              body: "We've reset the app password — please try Outlook again and let us know."
              is_internal: false
              cc:
                - name: "Office Manager"
                  email: "office@greendalesurveyors.example"
      responses:
        "201":
          description: Comment created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketComment
              example:
                data:
                  id: "4a6b8c0d-2e3f-4a5b-6c7d-8e9f0a1b2c3d"
                  org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                  ticket_id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
                  author_user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                  author_contact_id: null
                  body: "We've reset the app password — please try Outlook again and let us know."
                  is_internal: false
                  source_type: manual
                  source_message_id: null
                  created_at: "2026-06-10T10:12:45Z"
                  cc:
                    - name: "Office Manager"
                      email: "office@greendalesurveyors.example"
                  bcc: []
        "400":
          description: Missing body or invalid cc/bcc entry
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Ticket is already closed (ALREADY_CLOSED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/links:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-tickets:read
      summary: List linked entities for a ticket
      description: |
        Returns the related-items projection — linked agents, network
        devices, and clients — for the detail page panel. Empty arrays
        (never null) per category.
      operationId: listTicketLinks
      tags: [Tickets]
      responses:
        "200":
          description: Linked entities grouped by kind
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      agents:
                        type: array
                        items:
                          type: object
                          properties:
                            link_id: {type: string, format: uuid}
                            agent_id: {type: string, format: uuid}
                            hostname: {type: string}
                            link_type: {type: string}
                            created_at: {type: string, format: date-time}
                      network_devices:
                        type: array
                        items:
                          type: object
                          properties:
                            link_id: {type: string, format: uuid}
                            network_device_id: {type: string, format: uuid}
                            hostname: {type: string}
                            ip_address: {type: string}
                            link_type: {type: string}
                            created_at: {type: string, format: date-time}
                      clients:
                        type: array
                        items:
                          type: object
                          properties:
                            link_id: {type: string, format: uuid}
                            client_id: {type: string, format: uuid}
                            name: {type: string}
                            link_type: {type: string}
                            created_at: {type: string, format: date-time}
              example:
                data:
                  agents:
                    - link_id: "5d7e9f1a-3b4c-5d6e-7f8a-9b0c1d2e3f4a"
                      agent_id: "2c4d6e8f-0a1b-2c3d-4e5f-6a7b8c9d0e1f"
                      hostname: "GREENDALE-SALES-07"
                      link_type: "related"
                      created_at: "2026-06-10T09:18:00Z"
                  network_devices: []
                  clients: []
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/merge:
    parameters:
      - name: id
        in: path
        required: true
        description: Source ticket ID (the one merged away)
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Merge a ticket into another
      description: |
        Merges the source ticket ({id}) into `target_id`: comments,
        attachments, time entries, watchers, and entity links are
        rebased onto the target, and the source is stamped
        merged_into_id. GET on the source thereafter transparently
        returns the target. Errors: 400 self-merge, 409 ALREADY_MERGED /
        INVALID_STATE (source is terminal), 403 cross-org.
      operationId: mergeTicket
      tags: [Tickets]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_id]
              properties:
                target_id:
                  type: string
                  format: uuid
                  description: Destination ticket
            example:
              target_id: "9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b"
      responses:
        "200":
          description: Merge summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      source_id: {type: string, format: uuid}
                      target_id: {type: string, format: uuid}
                      comments_rebased: {type: integer}
                      attachments_rebased: {type: integer}
                      time_entries_rebased: {type: integer}
                      watchers_rebased: {type: integer}
                      entity_links_rebased: {type: integer}
                      audit_comment_id: {type: string, format: uuid}
              example:
                data:
                  source_id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
                  target_id: "9e8d7c6b-5a4f-3e2d-1c0b-9a8f7e6d5c4b"
                  comments_rebased: 3
                  attachments_rebased: 1
                  time_entries_rebased: 0
                  watchers_rebased: 2
                  entity_links_rebased: 1
                  audit_comment_id: "7b9c1d3e-5f6a-7b8c-9d0e-1f2a3b4c5d6e"
        "400":
          description: Missing target_id or self-merge
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Source already merged (ALREADY_MERGED) or not mergeable (INVALID_STATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/transition:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Transition ticket status
      description: |
        Runs the status state machine. Closing/cancelling with active
        timers is refused with 409 TIMERS_ACTIVE unless
        `discard_timers=true`. Other 409 codes: INVALID_TRANSITION,
        ALREADY_CLOSED, VERSION_CONFLICT (when `expected_updated_at` is
        stale).
      operationId: transitionTicket
      tags: [Tickets]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to:
                  type: string
                  enum: [new, open, pending, waiting_customer, resolved, closed, cancelled]
                  description: Target status
                discard_timers:
                  type: boolean
                  default: false
                  description: Drop active timers when closing/cancelling
                expected_updated_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: Optimistic-lock token (the updated_at last read)
            example:
              to: resolved
              expected_updated_at: "2026-06-10T10:12:45Z"
      responses:
        "200":
          description: Updated ticket
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Ticket
              example:
                data: *TicketExample
        "400":
          description: Invalid target status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: INVALID_TRANSITION, ALREADY_CLOSED, TIMERS_ACTIVE, or VERSION_CONFLICT
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/triage/assign:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Assign a triage-queue ticket to a client
      description: |
        Resolves a `needs_triage` ticket (opened by inbound email from an
        unrecognised sender): binds it to a client (and optionally a
        contact), clears needs_triage. `create_contact=true` creates a
        contact from the sender address when no `contact_id` is given;
        `add_domain_to_client=true` registers the sender's domain against
        the client so future mail routes automatically. Returns 409
        NOT_TRIAGE if the ticket is not in the triage queue.
      operationId: triageAssignTicket
      tags: [Tickets]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id]
              properties:
                client_id: {type: string, format: uuid}
                contact_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Must belong to both the org and the chosen client
                create_contact:
                  type: boolean
                  default: false
                  description: Create a contact from the sender address when contact_id is absent
                add_domain_to_client:
                  type: boolean
                  default: false
            example:
              client_id: "5b1c0d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e"
              create_contact: true
              add_domain_to_client: true
      responses:
        "200":
          description: Ticket assigned and removed from the triage queue
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: {type: boolean}
                      ticket_id: {type: string, format: uuid}
              example:
                data:
                  ok: true
                  ticket_id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
        "400":
          description: Missing client_id or cross-org reference (INVALID_REFERENCE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Ticket is not in the triage queue (NOT_TRIAGE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/triage/spam:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Mark a triage-queue ticket as spam
      description: |
        Adds the inbound sender to the org's email blocklist (exact
        address by default, whole domain with `scope=domain`) and deletes
        the ticket. ALSO requires the psa-email:write scope
        (PermPsaEmailConfigEdit) because it writes to the inbound
        blocklist. Only valid on needs_triage tickets (409 NOT_TRIAGE);
        refused with 409 TICKET_HAS_BILLED_TIME when the ticket has
        invoiced time. The request body is optional.
      operationId: triageSpamTicket
      tags: [Tickets]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scope:
                  type: string
                  enum: [address, domain]
                  default: address
                  description: Block the exact sender address or its whole domain
                notes: {type: string, nullable: true}
            example:
              scope: domain
              notes: "Persistent unsolicited marketing"
      responses:
        "200":
          description: Sender blocked and ticket deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      ok: {type: boolean}
                      pattern_type:
                        type: string
                        enum: [address, domain]
                      pattern: {type: string}
              example:
                data:
                  ok: true
                  pattern_type: domain
                  pattern: "spamcorp.example"
        "400":
          description: Ticket has no sender address to block (NO_SENDER)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: NOT_TRIAGE or TICKET_HAS_BILLED_TIME
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/unarchive:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Unarchive a ticket
      description: Clears archived_at — the inverse of archive. Idempotent.
      operationId: unarchiveTicket
      tags: [Tickets]
      responses:
        "200":
          description: Unarchived ticket
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Ticket
              example:
                data: *TicketExample
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/watchers:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: List ticket watchers
      description: |
        Lists users watching this ticket. Note the permission gate is
        psa.tickets.watch, which maps to the psa-tickets:write scope
        (even for the read).
      operationId: listTicketWatchers
      tags: [Tickets]
      responses:
        "200":
          description: Watcher list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &TicketWatcher
                          type: object
                          properties:
                            org_id: {type: string, format: uuid}
                            ticket_id: {type: string, format: uuid}
                            user_id: {type: string, format: uuid}
                            added_by: {type: string, format: uuid}
                            added_at: {type: string, format: date-time}
              example:
                data:
                  items:
                    - &TicketWatcherExample
                      org_id: "9a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d"
                      ticket_id: "0b6f1f9e-2f64-4f7e-9b1a-7d6f3f2a1c01"
                      user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      added_by: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
                      added_at: "2026-06-10T09:30:00Z"
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Add a ticket watcher
      description: |
        Adds an MSP-staff user as a watcher. Idempotent — adding an
        existing watcher returns 200 with the existing row instead of
        409.
      operationId: addTicketWatcher
      tags: [Tickets]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id:
                  type: string
                  format: uuid
                  description: Must be an MSP-staff member of this org
            example:
              user_id: "1d3e5f7a-9b0c-1d2e-3f4a-5b6c7d8e9f0a"
      responses:
        "200":
          description: Watcher already existed (idempotent re-add)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketWatcher
              example:
                data: *TicketWatcherExample
        "201":
          description: Watcher added
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TicketWatcher
              example:
                data: *TicketWatcherExample
        "400":
          description: user_id missing or not an MSP-staff member of this org
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/tickets/{id}/watchers/{user_id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
      - name: user_id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    delete:
      x-stability: stable
      x-required-scope: psa-tickets:write
      summary: Remove a ticket watcher
      operationId: removeTicketWatcher
      tags: [Tickets]
      responses:
        "204":
          description: Watcher removed
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /psa/time/entries:
    get:
      x-stability: stable
      x-required-scope: psa-time:read
      summary: List time entries
      operationId: listTimeEntries
      tags: [Time Tracking]
      description: |
        Lists time entries in the caller's organisation. Results are not
        paginated; narrow with the filters below.

        Authorisation rules: filtering by another user's `user_id`, or by a
        `timesheet_id` the caller does not own, requires the timesheet-approve
        permission (psa-time:write reviewers). When no `user_id`, `ticket_id`,
        or `timesheet_id` filter is supplied, non-reviewers see only their own
        entries.
      parameters:
        - name: user_id
          in: query
          description: Filter to one user's entries (approve permission required for other users)
          schema:
            type: string
            format: uuid
        - name: ticket_id
          in: query
          description: Filter to entries logged against one ticket
          schema:
            type: string
            format: uuid
        - name: timesheet_id
          in: query
          description: Filter to entries on one timesheet (must be the caller's own unless reviewer)
          schema:
            type: string
            format: uuid
        - name: period_start
          in: query
          description: Only entries created at or after this instant (RFC3339)
          schema:
            type: string
            format: date-time
        - name: period_end
          in: query
          description: Only entries created at or before this instant (RFC3339)
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: Time entry list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        timesheet_id: {type: string, format: uuid, nullable: true}
                        ticket_id: {type: string, format: uuid}
                        user_id: {type: string, format: uuid}
                        service_id: {type: string, format: uuid, nullable: true}
                        started_at: {type: string, format: date-time, nullable: true}
                        ended_at: {type: string, format: date-time, nullable: true}
                        duration_minutes: {type: integer}
                        billable_duration:
                          type: integer
                          description: Minutes after tenant rounding/minimum rules are applied
                        notes: {type: string}
                        source_type:
                          type: string
                          enum: [manual, timer, remote_session]
                        source_id: {type: string, format: uuid, nullable: true}
                        is_billable: {type: boolean}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - id: "7f9b3c1e-2d4a-4b8e-9f01-6a5c3d2e1f00"
                    org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                    timesheet_id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                    ticket_id: "11111111-2222-4333-8444-555555555555"
                    user_id: "99999999-8888-4777-8666-555555555555"
                    service_id: "deadbeef-cafe-4bad-8f00-d00d00000001"
                    started_at: "2026-06-10T09:00:00Z"
                    ended_at: "2026-06-10T09:42:00Z"
                    duration_minutes: 42
                    billable_duration: 45
                    notes: "Patched Exchange, rebooted, verified mail flow"
                    source_type: "timer"
                    source_id: null
                    is_billable: true
                    created_at: "2026-06-10T09:42:03Z"
                    updated_at: "2026-06-10T09:42:03Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
    post:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Create a time entry
      operationId: createTimeEntry
      tags: [Time Tracking]
      description: |
        Creates a manual time entry against a ticket. The entry is attached to
        the caller's draft timesheet for the period; if that timesheet has been
        submitted or approved the request is rejected with 409.
        `duration_minutes` must be between 1 and 1440 (24h per-entry limit).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ticket_id, duration_minutes]
              properties:
                ticket_id:
                  type: string
                  format: uuid
                service_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Billing service; falls back to the org default T&M service when omitted on billable entries
                started_at:
                  type: string
                  format: date-time
                  nullable: true
                ended_at:
                  type: string
                  format: date-time
                  nullable: true
                duration_minutes:
                  type: integer
                  minimum: 1
                  maximum: 1440
                notes:
                  type: string
                is_billable:
                  type: boolean
            example:
              ticket_id: "11111111-2222-4333-8444-555555555555"
              service_id: "deadbeef-cafe-4bad-8f00-d00d00000001"
              started_at: "2026-06-10T14:00:00Z"
              ended_at: "2026-06-10T14:30:00Z"
              duration_minutes: 30
              notes: "On-site printer swap"
              is_billable: true
      responses:
        "201":
          description: Time entry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The created time entry (same shape as list items)
              example:
                data:
                  id: "7f9b3c1e-2d4a-4b8e-9f01-6a5c3d2e1f00"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  timesheet_id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  ticket_id: "11111111-2222-4333-8444-555555555555"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  service_id: "deadbeef-cafe-4bad-8f00-d00d00000001"
                  started_at: "2026-06-10T14:00:00Z"
                  ended_at: "2026-06-10T14:30:00Z"
                  duration_minutes: 30
                  billable_duration: 30
                  notes: "On-site printer swap"
                  source_type: "manual"
                  source_id: null
                  is_billable: true
                  created_at: "2026-06-10T14:31:00Z"
                  updated_at: "2026-06-10T14:31:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Timesheet is not editable (already submitted or approved)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: Service does not belong to this organisation or is not active
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/entries/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-time:read
      summary: Get a time entry by ID
      operationId: getTimeEntry
      tags: [Time Tracking]
      responses:
        "200":
          description: Time entry details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Time entry (same shape as list items)
              example:
                data:
                  id: "7f9b3c1e-2d4a-4b8e-9f01-6a5c3d2e1f00"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  timesheet_id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  ticket_id: "11111111-2222-4333-8444-555555555555"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  service_id: null
                  started_at: null
                  ended_at: null
                  duration_minutes: 15
                  billable_duration: 15
                  notes: "Password reset"
                  source_type: "manual"
                  source_id: null
                  is_billable: false
                  created_at: "2026-06-09T08:10:00Z"
                  updated_at: "2026-06-09T08:10:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
    patch:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Update a time entry
      operationId: updateTimeEntry
      tags: [Time Tracking]
      description: |
        Partially updates a time entry. Omitted fields are left unchanged.
        Technicians may only edit their own entries; reviewers (timesheet
        approve permission) may edit anyone's. Entries on a submitted or
        approved timesheet cannot be edited (409).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                duration_minutes:
                  type: integer
                  minimum: 1
                  maximum: 1440
                  nullable: true
                notes:
                  type: string
                  nullable: true
                service_id:
                  type: string
                  format: uuid
                  nullable: true
                started_at:
                  type: string
                  format: date-time
                  nullable: true
                ended_at:
                  type: string
                  format: date-time
                  nullable: true
                is_billable:
                  type: boolean
                  nullable: true
            example:
              duration_minutes: 45
              notes: "Printer swap plus driver cleanup"
      responses:
        "200":
          description: Updated time entry
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Time entry (same shape as list items)
              example:
                data:
                  id: "7f9b3c1e-2d4a-4b8e-9f01-6a5c3d2e1f00"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  timesheet_id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  ticket_id: "11111111-2222-4333-8444-555555555555"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  service_id: "deadbeef-cafe-4bad-8f00-d00d00000001"
                  started_at: "2026-06-10T14:00:00Z"
                  ended_at: "2026-06-10T14:45:00Z"
                  duration_minutes: 45
                  billable_duration: 45
                  notes: "Printer swap plus driver cleanup"
                  source_type: "manual"
                  source_id: null
                  is_billable: true
                  created_at: "2026-06-10T14:31:00Z"
                  updated_at: "2026-06-10T15:02:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Timesheet is not editable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "422":
          description: Service does not belong to this organisation or is not active
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
    delete:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Delete a time entry
      operationId: deleteTimeEntry
      tags: [Time Tracking]
      description: |
        Deletes a time entry. Technicians may only delete their own entries;
        reviewers may delete anyone's. Entries on a submitted or approved
        timesheet cannot be deleted (409).
      responses:
        "204":
          description: Time entry deleted
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Timesheet is not editable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/settings:
    get:
      x-stability: stable
      x-required-scope: psa-settings:read
      summary: Get time tracking settings
      operationId: getTimeTrackingSettings
      tags: [Time Tracking]
      description: |
        Returns the tenant's time tracking configuration. Defaults (weekly
        period, round up to 15 minutes, 15-minute billing minimum) are
        returned when the org has never saved settings.
      responses:
        "200":
          description: Time tracking settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      org_id: {type: string, format: uuid}
                      period_type:
                        type: string
                        enum: [weekly, biweekly]
                      rounding_increment:
                        type: integer
                        enum: [6, 10, 15, 30]
                        description: Minutes to round billable durations to
                      rounding_direction:
                        type: string
                        enum: [up, nearest]
                      min_billing_minutes:
                        type: integer
                        minimum: 1
                      default_time_service_id:
                        type: string
                        format: uuid
                        nullable: true
                        description: Fallback T&M service applied to billable entries created without an explicit service; null means no default
              example:
                data:
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  period_type: "weekly"
                  rounding_increment: 15
                  rounding_direction: "up"
                  min_billing_minutes: 15
                  default_time_service_id: "deadbeef-cafe-4bad-8f00-d00d00000001"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
    put:
      x-stability: stable
      x-required-scope: psa-settings:write
      summary: Update time tracking settings
      operationId: updateTimeTrackingSettings
      tags: [Time Tracking]
      description: |
        Replaces the tenant's period, rounding, and minimum-billing settings.
        Does not modify `default_time_service_id` — use the
        `/psa/time/settings/default-service` endpoint for that. The response
        reflects the full persisted settings including the (unchanged)
        default service.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [period_type, rounding_increment, rounding_direction, min_billing_minutes]
              properties:
                period_type:
                  type: string
                  enum: [weekly, biweekly]
                rounding_increment:
                  type: integer
                  enum: [6, 10, 15, 30]
                rounding_direction:
                  type: string
                  enum: [up, nearest]
                min_billing_minutes:
                  type: integer
                  minimum: 1
            example:
              period_type: "weekly"
              rounding_increment: 6
              rounding_direction: "nearest"
              min_billing_minutes: 6
      responses:
        "200":
          description: Saved settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Time tracking settings (same shape as the GET response)
              example:
                data:
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  period_type: "weekly"
                  rounding_increment: 6
                  rounding_direction: "nearest"
                  min_billing_minutes: 6
                  default_time_service_id: "deadbeef-cafe-4bad-8f00-d00d00000001"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/settings/default-service:
    put:
      x-stability: stable
      x-required-scope: psa-settings:write
      summary: Set or clear the default T&M service
      operationId: updateDefaultTimeService
      tags: [Time Tracking]
      description: |
        Sets (or clears, when `service_id` is an explicit JSON null) the org's
        default T&M service — the fallback applied to billable time entries
        created without an explicit service, notably timer stops. The
        `service_id` key is required: omitting it is rejected with 400 so an
        empty body cannot silently clear the default. The service must be an
        active service in the caller's organisation (422 otherwise).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [service_id]
              properties:
                service_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: UUID to set the default, explicit null to clear it
            example:
              service_id: "deadbeef-cafe-4bad-8f00-d00d00000001"
      responses:
        "200":
          description: Saved settings reflecting the new default
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Time tracking settings (same shape as GET /psa/time/settings)
              example:
                data:
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  period_type: "weekly"
                  rounding_increment: 15
                  rounding_direction: "up"
                  min_billing_minutes: 15
                  default_time_service_id: "deadbeef-cafe-4bad-8f00-d00d00000001"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          description: Service does not belong to this organisation or is not active
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/settings/reconcile-unassigned:
    post:
      x-stability: stable
      x-required-scope: psa-settings:write
      summary: Assign the default service to unassigned billable entries
      operationId: reconcileUnassignedBillable
      tags: [Time Tracking]
      description: |
        Stamps the org's configured default T&M service onto every billable,
        unbilled time entry that has no service, so historical entries start
        billing. An active default service must be configured first: requests
        are rejected with 422 when no default is set or when the configured
        default has since been archived.
      responses:
        "200":
          description: Number of entries updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      updated:
                        type: integer
                        format: int64
              example:
                data:
                  updated: 37
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          description: No active default T&M service is configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/settings/services:
    get:
      x-stability: stable
      x-required-scope: psa-settings:read
      summary: List active services for the default-service picker
      operationId: listTimeSettingsServices
      tags: [Time Tracking]
      description: |
        Returns the org's active billing services (id and name only), sorted
        by name, for the default T&M service selector on the time tracking
        settings page.
      responses:
        "200":
          description: Active services
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        name: {type: string}
              example:
                data:
                  - id: "deadbeef-cafe-4bad-8f00-d00d00000001"
                    name: "Remote Support (T&M)"
                  - id: "deadbeef-cafe-4bad-8f00-d00d00000002"
                    name: "On-site Support (T&M)"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/settings/unassigned-billable:
    get:
      x-stability: stable
      x-required-scope: psa-settings:read
      summary: Count billable time entries with no service
      operationId: getUnassignedBillableCount
      tags: [Time Tracking]
      description: |
        Returns the count of billable, not-yet-billed time entries that have
        no service attached — entries that will not bill until reconciled.
        This is the preview half of the reconcile flow.
      responses:
        "200":
          description: Count of unassigned billable entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      count:
                        type: integer
              example:
                data:
                  count: 37
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timer-prefs:
    get:
      x-stability: stable
      x-required-scope: psa-time:read
      summary: Get the caller's timer preferences
      operationId: getTimerPrefs
      tags: [Time Tracking]
      description: |
        Returns the authenticated user's timer preferences. Defaults
        (auto_start false, auto_pause_others true) are returned when the user
        has never saved preferences.
      responses:
        "200":
          description: Timer preferences
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      user_id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      auto_start:
                        type: boolean
                        description: Start a timer automatically when opening a ticket
                      auto_pause_others:
                        type: boolean
                        description: Pause other running timers when a new one starts
                      updated_at: {type: string, format: date-time}
              example:
                data:
                  user_id: "99999999-8888-4777-8666-555555555555"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  auto_start: false
                  auto_pause_others: true
                  updated_at: "2026-06-01T12:00:00Z"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
    put:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Update the caller's timer preferences
      operationId: updateTimerPrefs
      tags: [Time Tracking]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                auto_start:
                  type: boolean
                auto_pause_others:
                  type: boolean
            example:
              auto_start: true
              auto_pause_others: true
      responses:
        "200":
          description: Saved timer preferences
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Timer preferences (same shape as the GET response)
              example:
                data:
                  user_id: "99999999-8888-4777-8666-555555555555"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  auto_start: true
                  auto_pause_others: true
                  updated_at: "2026-06-11T12:00:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timers:
    get:
      x-stability: stable
      x-required-scope: psa-time:read
      summary: List the caller's live timers
      operationId: listTimers
      tags: [Time Tracking]
      description: |
        Returns every live timer (running or paused) owned by the
        authenticated user. A timer is running when `running_since` is
        non-null; total elapsed seconds = `accumulated_seconds` plus the time
        since `running_since` when running.
      responses:
        "200":
          description: Timer list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        user_id: {type: string, format: uuid}
                        ticket_id: {type: string, format: uuid}
                        created_at: {type: string, format: date-time}
                        running_since:
                          type: string
                          format: date-time
                          nullable: true
                          description: Null when the timer is paused
                        accumulated_seconds:
                          type: integer
                          description: Elapsed seconds carried across pause/resume cycles
                        notes: {type: string}
                        label: {type: string}
              example:
                data:
                  - id: "aa11bb22-cc33-4d44-8e55-ff6677889900"
                    org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                    user_id: "99999999-8888-4777-8666-555555555555"
                    ticket_id: "11111111-2222-4333-8444-555555555555"
                    created_at: "2026-06-11T10:00:00Z"
                    running_since: "2026-06-11T10:20:00Z"
                    accumulated_seconds: 600
                    notes: "Investigating slow logons"
                    label: "INC-1042"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
    post:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Start a new timer
      operationId: createTimer
      tags: [Time Tracking]
      description: |
        Starts a new running timer against a ticket. Set `pause_others` to
        pause every other running timer the caller owns (single-active-timer
        behaviour).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ticket_id]
              properties:
                ticket_id:
                  type: string
                  format: uuid
                notes:
                  type: string
                label:
                  type: string
                pause_others:
                  type: boolean
                  description: Pause the caller's other running timers when this one starts
            example:
              ticket_id: "11111111-2222-4333-8444-555555555555"
              notes: "Investigating slow logons"
              label: "INC-1042"
              pause_others: true
      responses:
        "201":
          description: Timer started
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The created timer (same shape as list items)
              example:
                data:
                  id: "aa11bb22-cc33-4d44-8e55-ff6677889900"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  ticket_id: "11111111-2222-4333-8444-555555555555"
                  created_at: "2026-06-11T10:00:00Z"
                  running_since: "2026-06-11T10:00:00Z"
                  accumulated_seconds: 0
                  notes: "Investigating slow logons"
                  label: "INC-1042"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timers/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Update a timer's notes, label, or ticket
      operationId: updateTimer
      tags: [Time Tracking]
      description: |
        Edits a live timer the caller owns. Omitted fields are left unchanged.
        Moving the timer to a different ticket requires the ticket to belong
        to the caller's organisation (404 otherwise).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  nullable: true
                label:
                  type: string
                  nullable: true
                ticket_id:
                  type: string
                  format: uuid
                  nullable: true
            example:
              notes: "Root cause: GPO drive-mapping timeout"
              label: "INC-1042 follow-up"
      responses:
        "200":
          description: Updated timer
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Timer (same shape as list items)
              example:
                data:
                  id: "aa11bb22-cc33-4d44-8e55-ff6677889900"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  ticket_id: "11111111-2222-4333-8444-555555555555"
                  created_at: "2026-06-11T10:00:00Z"
                  running_since: "2026-06-11T10:20:00Z"
                  accumulated_seconds: 600
                  notes: "Root cause: GPO drive-mapping timeout"
                  label: "INC-1042 follow-up"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
    delete:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Discard a timer without logging time
      operationId: discardTimer
      tags: [Time Tracking]
      description: |
        Removes a live timer without creating a time entry. Use when the timer
        was started in error; use the stop endpoint to commit the elapsed time
        instead.
      responses:
        "204":
          description: Timer discarded
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timers/{id}/pause:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Pause a running timer
      operationId: pauseTimer
      tags: [Time Tracking]
      description: |
        Pauses a running timer: elapsed time is folded into
        `accumulated_seconds` and `running_since` becomes null. Pausing an
        already-paused timer returns 409.
      responses:
        "200":
          description: Paused timer
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Timer (same shape as list items)
              example:
                data:
                  id: "aa11bb22-cc33-4d44-8e55-ff6677889900"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  ticket_id: "11111111-2222-4333-8444-555555555555"
                  created_at: "2026-06-11T10:00:00Z"
                  running_since: null
                  accumulated_seconds: 1800
                  notes: "Investigating slow logons"
                  label: "INC-1042"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Timer is already paused
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timers/{id}/resume:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Resume a paused timer
      operationId: resumeTimer
      tags: [Time Tracking]
      description: |
        Resumes a paused timer. The request body is optional; set
        `pause_others` to pause the caller's other running timers when this
        one resumes. Resuming a timer that is already running returns 409.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                pause_others:
                  type: boolean
                  default: false
            example:
              pause_others: true
      responses:
        "200":
          description: Resumed timer
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Timer (same shape as list items)
              example:
                data:
                  id: "aa11bb22-cc33-4d44-8e55-ff6677889900"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  ticket_id: "11111111-2222-4333-8444-555555555555"
                  created_at: "2026-06-11T10:00:00Z"
                  running_since: "2026-06-11T11:05:00Z"
                  accumulated_seconds: 1800
                  notes: "Investigating slow logons"
                  label: "INC-1042"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Timer is already running
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timers/{id}/stop:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Stop a timer and commit it as a time entry
      operationId: stopTimer
      tags: [Time Tracking]
      description: |
        Stops a live timer, converts its elapsed time into a time entry
        (source_type `timer`) on the caller's draft timesheet, and removes the
        timer. Returns the created time entry, not the timer. Elapsed time is
        capped at 24h per entry.
      responses:
        "200":
          description: Time entry created from the stopped timer
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The created time entry (same shape as /psa/time/entries list items)
              example:
                data:
                  id: "7f9b3c1e-2d4a-4b8e-9f01-6a5c3d2e1f00"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  timesheet_id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  ticket_id: "11111111-2222-4333-8444-555555555555"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  service_id: "deadbeef-cafe-4bad-8f00-d00d00000001"
                  started_at: "2026-06-11T10:00:00Z"
                  ended_at: "2026-06-11T11:25:00Z"
                  duration_minutes: 65
                  billable_duration: 75
                  notes: "Investigating slow logons"
                  source_type: "timer"
                  source_id: null
                  is_billable: true
                  created_at: "2026-06-11T11:25:00Z"
                  updated_at: "2026-06-11T11:25:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timesheets:
    get:
      x-stability: stable
      x-required-scope: psa-time:read
      summary: List timesheets
      operationId: listTimesheets
      tags: [Time Tracking]
      description: |
        Lists timesheets. Without `user_id` the caller's own timesheets are
        returned. Passing another user's `user_id` requires the
        timesheet-approve permission. Results are not paginated.
      parameters:
        - name: user_id
          in: query
          description: Filter to one user's timesheets (approve permission required for other users)
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          schema:
            type: string
            enum: [draft, submitted, approved, rejected]
      responses:
        "200":
          description: Timesheet list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        user_id: {type: string, format: uuid}
                        period_start:
                          type: string
                          format: date-time
                          description: Period start date (midnight UTC)
                        period_end:
                          type: string
                          format: date-time
                        status:
                          type: string
                          enum: [draft, submitted, approved, rejected]
                        submitted_at: {type: string, format: date-time, nullable: true}
                        reviewed_by: {type: string, format: uuid, nullable: true}
                        reviewed_at: {type: string, format: date-time, nullable: true}
                        rejection_note: {type: string, nullable: true}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
              example:
                data:
                  - id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                    org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                    user_id: "99999999-8888-4777-8666-555555555555"
                    period_start: "2026-06-08T00:00:00Z"
                    period_end: "2026-06-14T00:00:00Z"
                    status: "submitted"
                    submitted_at: "2026-06-10T17:00:00Z"
                    reviewed_by: null
                    reviewed_at: null
                    rejection_note: null
                    created_at: "2026-06-08T09:00:00Z"
                    updated_at: "2026-06-10T17:00:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timesheets/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: psa-time:read
      summary: Get a timesheet with its time entries
      operationId: getTimesheet
      tags: [Time Tracking]
      responses:
        "200":
          description: Timesheet with nested time entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Timesheet fields (same shape as list items) plus a nested entries array
                    properties:
                      entries:
                        type: array
                        items:
                          type: object
                          description: Time entry (same shape as /psa/time/entries list items)
              example:
                data:
                  id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  period_start: "2026-06-08T00:00:00Z"
                  period_end: "2026-06-14T00:00:00Z"
                  status: "draft"
                  submitted_at: null
                  reviewed_by: null
                  reviewed_at: null
                  rejection_note: null
                  created_at: "2026-06-08T09:00:00Z"
                  updated_at: "2026-06-08T09:00:00Z"
                  entries:
                    - id: "7f9b3c1e-2d4a-4b8e-9f01-6a5c3d2e1f00"
                      ticket_id: "11111111-2222-4333-8444-555555555555"
                      duration_minutes: 42
                      billable_duration: 45
                      source_type: "timer"
                      is_billable: true
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timesheets/{id}/approve:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Approve a submitted timesheet
      operationId: approveTimesheet
      tags: [Time Tracking]
      description: |
        Transitions a timesheet from `submitted` to `approved`, releasing its
        billable time for invoicing. Only the `submitted` → `approved`
        transition is allowed (409 otherwise). Separation of duties is
        enforced: a reviewer cannot approve their own timesheet even with the
        approve permission (403, code SELF_REVIEW_FORBIDDEN).
      responses:
        "200":
          description: Timesheet approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Updated timesheet (same shape as list items)
              example:
                data:
                  id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  period_start: "2026-06-08T00:00:00Z"
                  period_end: "2026-06-14T00:00:00Z"
                  status: "approved"
                  submitted_at: "2026-06-10T17:00:00Z"
                  reviewed_by: "abcdefab-cdef-4abc-8def-abcdefabcdef"
                  reviewed_at: "2026-06-11T09:15:00Z"
                  rejection_note: null
                  created_at: "2026-06-08T09:00:00Z"
                  updated_at: "2026-06-11T09:15:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Timesheet cannot be approved from its current status (only submitted can be approved)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timesheets/{id}/reject:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Reject a submitted timesheet
      operationId: rejectTimesheet
      tags: [Time Tracking]
      description: |
        Transitions a timesheet from `submitted` to `rejected`, recording a
        mandatory rejection note. The owner then reopens it (`rejected` →
        `draft`) to fix and resubmit. Only the `submitted` → `rejected`
        transition is allowed (409 otherwise). A reviewer cannot reject their
        own timesheet (403, code SELF_REVIEW_FORBIDDEN).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note:
                  type: string
                  description: Reason for rejection (must be non-empty)
            example:
              note: "Tuesday entries are missing ticket references — please fix and resubmit."
      responses:
        "200":
          description: Timesheet rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Updated timesheet (same shape as list items)
              example:
                data:
                  id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  period_start: "2026-06-08T00:00:00Z"
                  period_end: "2026-06-14T00:00:00Z"
                  status: "rejected"
                  submitted_at: "2026-06-10T17:00:00Z"
                  reviewed_by: "abcdefab-cdef-4abc-8def-abcdefabcdef"
                  reviewed_at: "2026-06-11T09:20:00Z"
                  rejection_note: "Tuesday entries are missing ticket references — please fix and resubmit."
                  created_at: "2026-06-08T09:00:00Z"
                  updated_at: "2026-06-11T09:20:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Timesheet cannot be rejected from its current status (only submitted can be rejected)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timesheets/{id}/reopen:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Reopen a rejected timesheet
      operationId: reopenTimesheet
      tags: [Time Tracking]
      description: |
        Transitions a `rejected` timesheet back to `draft` so its entries can
        be corrected and resubmitted. Only the timesheet owner may reopen it
        (403 otherwise); only the `rejected` → `draft` transition is allowed
        (409 otherwise).
      responses:
        "200":
          description: Timesheet reopened to draft
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Updated timesheet (same shape as list items)
              example:
                data:
                  id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  period_start: "2026-06-08T00:00:00Z"
                  period_end: "2026-06-14T00:00:00Z"
                  status: "draft"
                  submitted_at: null
                  reviewed_by: null
                  reviewed_at: null
                  rejection_note: null
                  created_at: "2026-06-08T09:00:00Z"
                  updated_at: "2026-06-11T08:30:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Only a rejected timesheet can be reopened
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /psa/time/timesheets/{id}/submit:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    post:
      x-stability: stable
      x-required-scope: psa-time:write
      summary: Submit a timesheet for approval
      operationId: submitTimesheet
      tags: [Time Tracking]
      description: |
        Transitions a timesheet from `draft` to `submitted`. The full state
        machine is:

        - `draft` → `submitted` (this endpoint)
        - `submitted` → `approved` (approve endpoint)
        - `submitted` → `rejected` (reject endpoint, with a mandatory note)
        - `rejected` → `draft` (reopen endpoint — the owner fixes and resubmits)

        Any other transition is rejected with 409. Once submitted, the
        timesheet's entries are locked against edit/delete until it is
        rejected and reopened.
      responses:
        "200":
          description: Timesheet submitted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: Updated timesheet (same shape as list items)
              example:
                data:
                  id: "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f"
                  org_id: "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
                  user_id: "99999999-8888-4777-8666-555555555555"
                  period_start: "2026-06-08T00:00:00Z"
                  period_end: "2026-06-14T00:00:00Z"
                  status: "submitted"
                  submitted_at: "2026-06-10T17:00:00Z"
                  reviewed_by: null
                  reviewed_at: null
                  rejection_note: null
                  created_at: "2026-06-08T09:00:00Z"
                  updated_at: "2026-06-10T17:00:00Z"
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "409":
          description: Timesheet cannot be submitted from its current status (only draft can be submitted)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429":
          $ref: '#/components/responses/RateLimited'
  /public/invoice-refresh/{token}:
    parameters:
      - name: token
        in: path
        required: true
        description: Invoice-refresh magic-link token (UUID) from the original invoice email
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      operationId: preflightPublicInvoiceRefresh
      summary: Preflight an invoice email refresh (public magic link)
      description: |
        Unauthenticated magic-link preflight. Returns minimal invoice
        metadata (number, formatted total, masked recipient email) so
        the public page can confirm the action before the user clicks
        "Send". No state changes. Dead/revoked/expired tokens return
        404; invoices no longer in a sendable state return 410.
      tags: [Client Portal]
      security: []
      responses:
        "200":
          description: Refresh preflight
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      invoice_number: {type: string, example: "INV-2026-0042"}
                      total_formatted: {type: string, example: "£2,052.00"}
                      currency_code: {type: string, example: GBP}
                      recipient_mask:
                        type: string
                        description: Masked recipient email, e.g. "j***@example.com"
                      refresh_count: {type: integer}
                      cooldown_secs:
                        type: integer
                        description: 0 when ready; seconds remaining when the 48h per-token cooldown is active
        "400": {$ref: '#/components/responses/ValidationError'}
        "404": {$ref: '#/components/responses/NotFound'}
        "410":
          description: Invoice is no longer in a sendable state (INVOICE_NOT_SENDABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
    post:
      x-stability: internal
      operationId: refreshPublicInvoiceEmail
      summary: Re-send the invoice email (public magic link)
      description: |
        Unauthenticated magic-link action: re-sends the invoice email
        (with a fresh PDF download link) to the recipient recorded at
        token creation — never to the caller. Enforces a 48-hour
        per-token cooldown (429 with a human-readable message) on top of
        the per-IP login-class rate limit. No request body.
      tags: [Client Portal]
      security: []
      responses:
        "200":
          description: Email sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      sent: {type: boolean}
                      recipient_mask: {type: string}
              example:
                data:
                  sent: true
                  recipient_mask: "a***@greendalesurveyors.example"
        "400": {$ref: '#/components/responses/ValidationError'}
        "404": {$ref: '#/components/responses/NotFound'}
        "410":
          description: Invoice is no longer in a sendable state (INVOICE_NOT_SENDABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: Email send failed (SEND_FAILED) — safe to retry shortly
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "503":
          description: Object storage not configured (STORAGE_UNAVAILABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /public/quotes/{token}:
    parameters:
      - name: token
        in: path
        required: true
        description: Quote share token (UUID) from the quote email; revoked or expired tokens return 404
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      operationId: viewPublicQuote
      summary: View a quote (public magic link)
      description: |
        Unauthenticated quote-portal view. Possession of the share token
        is the only credential; it grants read + accept + decline +
        optional-toggle on exactly one quote. Returns the full
        render-ready view (quote, sections, items, signature when
        accepted, org branding bits). The first hit flips a sent quote
        to `viewed` and stamps viewed_first_at/viewed_last_at.
        `org_logo_url` is omitted when unset.
      tags: [Client Portal]
      security: []
      responses:
        "200":
          description: Render-ready quote view
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      quote:
                        type: object
                        description: |
                          One row from the quotes table (same shape as
                          the MSP surface — see the Quotes tag).
                          Status lifecycle: draft -> pending_approval ->
                          approved -> sent -> viewed ->
                          accepted/declined, with needs_info, expired,
                          withdrawn and superseded branches. All *_cents
                          fields are integer money in the quote's
                          currency. Note internal_notes is part of the
                          serialised struct.
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          quote_number:
                            type: string
                            description: Per-org human number in the form QN-YYYY-NNNN
                          client_id: {type: string, format: uuid}
                          site_id: {type: string, format: uuid, nullable: true}
                          contact_id: {type: string, format: uuid, nullable: true}
                          title: {type: string}
                          summary: {type: string}
                          status:
                            type: string
                            enum: [draft, pending_approval, approved, sent, viewed, accepted, declined, expired, withdrawn, superseded, needs_info]
                          subtotal_cents: {type: integer, format: int64}
                          discount_cents: {type: integer, format: int64}
                          tax_cents: {type: integer, format: int64}
                          total_cents: {type: integer, format: int64}
                          currency_code:
                            type: string
                            description: ISO 4217 (3 chars)
                          terms_text: {type: string}
                          customer_notes: {type: string}
                          internal_notes: {type: string}
                          expires_at: {type: string, format: date-time, nullable: true}
                          sent_at: {type: string, format: date-time, nullable: true}
                          viewed_first_at: {type: string, format: date-time, nullable: true}
                          viewed_last_at: {type: string, format: date-time, nullable: true}
                          accepted_at: {type: string, format: date-time, nullable: true}
                          declined_at: {type: string, format: date-time, nullable: true}
                          withdrawn_at: {type: string, format: date-time, nullable: true}
                          approval_state:
                            type: string
                            enum: [not_required, pending, approved, rejected]
                          approval_threshold_at_send: {type: integer, format: int64, nullable: true}
                          approval_requested_at: {type: string, format: date-time, nullable: true}
                          approval_requested_by: {type: string, format: uuid, nullable: true}
                          approver_id: {type: string, format: uuid, nullable: true}
                          approval_decided_at: {type: string, format: date-time, nullable: true}
                          approval_rejection_reason: {type: string}
                          creates_project: {type: boolean}
                          project_template_id: {type: string, format: uuid, nullable: true}
                          project_name_override: {type: string}
                          owner_user_id: {type: string, format: uuid, nullable: true}
                          spawned_invoice_id: {type: string, format: uuid, nullable: true}
                          spawned_ticket_id: {type: string, format: uuid, nullable: true}
                          spawned_project_id: {type: string, format: uuid, nullable: true}
                          spawned_contract_id: {type: string, format: uuid, nullable: true}
                          parent_quote_id: {type: string, format: uuid, nullable: true}
                          version:
                            type: integer
                            description: 1 for the original; increments per revision
                          expiry_reminder_sent_at: {type: string, format: date-time, nullable: true}
                          org_currency_code: {type: string}
                          fx_rate_to_org_currency:
                            type: string
                            nullable: true
                            description: Decimal FX rate encoded as a string
                          fx_rate_at: {type: string, format: date-time, nullable: true}
                          fx_org_currency_total_cents: {type: integer, format: int64, nullable: true}
                          properties:
                            type: object
                            description: Free-form JSON extension bag
                          created_at: {type: string, format: date-time}
                          updated_at: {type: string, format: date-time}
                          created_by: {type: string, format: uuid, nullable: true}
                      sections:
                        type: array
                        items:
                          type: object
                          description: One row from the quote_sections table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            quote_id: {type: string, format: uuid}
                            title: {type: string}
                            description: {type: string}
                            sequence: {type: integer}
                            show_subtotal: {type: boolean}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      items:
                        type: array
                        items:
                          type: object
                          description: |
                            One row from the quote_items table. quantity
                            is a decimal encoded as a string; all
                            *_cents fields are integer money in the
                            quote currency.
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            quote_id: {type: string, format: uuid}
                            section_id: {type: string, format: uuid, nullable: true}
                            sequence: {type: integer}
                            kind:
                              type: string
                              enum: [line, discount]
                            service_id: {type: string, format: uuid, nullable: true}
                            product_id: {type: string, format: uuid, nullable: true}
                            tax_code_id: {type: string, format: uuid, nullable: true}
                            description: {type: string}
                            quantity:
                              type: string
                              description: Decimal encoded as a string (e.g. "1.5000")
                            unit_price_cents: {type: integer, format: int64}
                            subtotal_cents: {type: integer, format: int64}
                            tax_cents: {type: integer, format: int64}
                            total_cents: {type: integer, format: int64}
                            is_optional:
                              type: boolean
                              description: Customer can toggle this line via the optional endpoint
                            is_optional_selected: {type: boolean}
                            alternate_group_id: {type: string, format: uuid, nullable: true}
                            discount_pct_bps:
                              type: integer
                              description: Per-line discount in basis points (1000 = 10%)
                            is_recurring: {type: boolean}
                            recurring_billing_frequency:
                              type: string
                              enum: [one_off, weekly, biweekly, monthly, quarterly, semi_annual, annual]
                              description: Present only when is_recurring=true
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      signature:
                        type: object
                        nullable: true
                        description: Immutable accept record; null until the quote is accepted (omitted when null)
                        properties:
                          id: {type: string, format: uuid}
                          org_id: {type: string, format: uuid}
                          quote_id: {type: string, format: uuid}
                          signed_name: {type: string}
                          signed_email: {type: string}
                          signed_position: {type: string}
                          accepted_terms_text: {type: string}
                          selected_optional_item_ids:
                            type: array
                            items: {type: string, format: uuid}
                          signed_at: {type: string, format: date-time}
                          ip_address: {type: string, nullable: true}
                          user_agent: {type: string}
                      org_name: {type: string}
                      org_accent_color:
                        type: string
                        description: Hex colour; defaults to "#0b6cf2" when no branding is configured
                      org_logo_url:
                        type: string
                        description: Omitted when unset
                      acceptance_text:
                        type: string
                        description: Org-level default acceptance terms shown above the accept button
                      currency_symbol: {type: string, example: "£"}
        "404": {$ref: '#/components/responses/NotFound'}
  /public/quotes/{token}/accept:
    parameters:
      - name: token
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      operationId: acceptPublicQuote
      summary: Accept and sign a quote (public magic link)
      description: |
        Typed-name acceptance. Records an immutable signature (name,
        email, position, accepted terms snapshot, IP, user agent) and
        spawns the follow-on records: always a ticket; an invoice when
        any one-off lines exist; a contract when recurring lines exist;
        a project when the quote is flagged creates_project.
        `signed_email` defaults to the token's recipient when omitted;
        `accepted_terms_text` defaults to the org's standard acceptance
        text. Already-terminal quotes return 409. Rate-limited in the
        login bucket to stop a leaked link being abused.
      tags: [Client Portal]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signed_name]
              properties:
                signed_name: {type: string}
                signed_email:
                  type: string
                  description: Defaults to the token's recipient_email when blank
                signed_position: {type: string}
                accepted_terms_text:
                  type: string
                  description: Defaults to the org's standard acceptance text when blank
            example:
              signed_name: "Sarah Pemberton"
              signed_position: "Office Manager"
      responses:
        "200":
          description: Quote accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      signed_at: {type: string, format: date-time}
                      invoice_id:
                        type: string
                        format: uuid
                        nullable: true
                        description: Omitted when no invoice was raised (all lines recurring)
                      ticket_id: {type: string, format: uuid}
                      project_id: {type: string, format: uuid, nullable: true}
                      contract_id:
                        type: string
                        format: uuid
                        nullable: true
                        description: Omitted unless recurring lines spawned a contract
        "400": {$ref: '#/components/responses/ValidationError'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in an acceptable state (INVALID_TRANSITION)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /public/quotes/{token}/decline:
    parameters:
      - name: token
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      operationId: declinePublicQuote
      summary: Decline a quote (public magic link)
      description: |
        Marks the quote declined with an optional free-text reason. The
        body is optional and parse errors are ignored. Terminal quotes
        return 409. Rate-limited in the login bucket.
      tags: [Client Portal]
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: {type: string}
            example:
              reason: "Gone with another supplier this time."
      responses:
        "204":
          description: Quote declined
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is not in a declinable state (INVALID_TRANSITION)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /public/quotes/{token}/items/{item_id}/optional:
    parameters:
      - name: token
        in: path
        required: true
        schema: {type: string, format: uuid}
      - name: item_id
        in: path
        required: true
        schema: {type: string, format: uuid}
    patch:
      x-stability: internal
      operationId: togglePublicQuoteItemOptional
      summary: Toggle an optional quote line (public magic link)
      description: |
        Selects/deselects an optional line so the customer can shape the
        quote before accepting. The item must belong to the token's
        quote (403 otherwise) and be flagged is_optional. On success
        returns the recalculated quote totals; falls back to 204 if the
        post-update re-read fails. Rate-limited in the login bucket.
      tags: [Client Portal]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                selected: {type: boolean}
            example:
              selected: true
      responses:
        "200":
          description: Updated quote totals
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      subtotal_cents: {type: integer, format: int64}
                      discount_cents: {type: integer, format: int64}
                      tax_cents: {type: integer, format: int64}
                      total_cents: {type: integer, format: int64}
        "204":
          description: Toggle applied but totals could not be re-read
        "400": {$ref: '#/components/responses/ValidationError'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Quote is in a terminal state (INVALID_TRANSITION)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /public/quotes/{token}/pdf:
    parameters:
      - name: token
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      operationId: downloadPublicQuotePDF
      summary: Download the quote PDF (public magic link)
      description: |
        Streams the rendered quote PDF inline
        (`Content-Disposition: inline`). Same token-only authentication
        as the quote view.
      tags: [Client Portal]
      security: []
      responses:
        "200":
          description: Rendered PDF
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        "404": {$ref: '#/components/responses/NotFound'}
  /qbo/webhook:
    post:
      x-stability: internal
      operationId: receiveQBOWebhook
      summary: QuickBooks Online webhook
      description: |
        Receives Intuit entity-change notifications for all tenants on one
        platform-wide URL. Authentication: `intuit-signature` header carrying
        the **base64**-encoded HMAC-SHA256 of the exact raw body, keyed with
        the per-environment Intuit verifier token
        (`cfg.QBO.ActiveWebhookVerifierToken()`); constant-time compared.
        Tenant context is resolved AFTER verification by `realmId` lookup in
        qbo_connections (unknown realms are persisted with a NULL org for
        forensics). The delivery is persisted to qbo_webhook_events and a River
        job is enqueued; 200 is returned as soon as the audit row is durable so
        Intuit's 24-hour retry budget is not burned by slow downstream work.
      tags: ["Webhooks (Inbound)"]
      security: []
      parameters:
        - name: intuit-signature
          in: header
          required: true
          description: Base64-encoded HMAC-SHA256 of the raw body, keyed with the Intuit webhook verifier token.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                eventNotifications:
                  type: array
                  description: One entry per realm; each carries a dataChangeEvent with the changed entities.
                  items:
                    type: object
                    properties:
                      realmId:
                        type: string
                      dataChangeEvent:
                        type: object
                        properties:
                          entities:
                            type: array
                            items:
                              type: object
      responses:
        "200":
          description: Delivery persisted and dispatched for async processing.
        "400":
          description: Signature verified but payload failed to parse (so Intuit stops retrying).
        "401":
          description: Signature verification failed (plain text, no parse hints leaked).
        "500":
          description: Audit-row persist failed — Intuit will retry.
        "503":
          description: No verifier token configured — refused so retries surface the misconfiguration.
  /registration-tokens/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Registration token ID
        schema:
          type: string
          format: uuid
    delete:
      x-stability: beta
      x-required-scope: sites:write
      summary: Revoke a registration token
      description: |
        Hard-deletes the token. Registration validates by hash lookup, so a
        deleted token is immediately unusable. Already-registered agents are
        unaffected — the token only gates enrolment.
      operationId: revokeRegistrationToken
      tags: [Agent Enrolment]
      responses:
        "204":
          description: Token revoked
        "400":
          description: Invalid token ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
    patch:
      x-stability: beta
      x-required-scope: sites:write
      summary: Extend (or shorten) a registration token's expiry
      description: |
        Re-anchors `expires_at` to **now + `expires_in`**. The new expiry
        replaces the old one entirely, so this works both to extend and to
        shorten a token's life. Also works on expired-but-not-yet-deleted
        tokens — expiry is evaluated at registration time, so reviving a
        token is safe.

        `expires_in` is a Go duration string (e.g. `24h`, `720h`), capped at
        `8760h` (12 months) — the same ceiling applied at generation.
      operationId: extendRegistrationToken
      tags: [Agent Enrolment]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [expires_in]
              properties:
                expires_in:
                  type: string
                  description: Positive Go duration string measured from now; maximum 8760h (12 months)
                  example: "720h"
            examples:
              thirtyDays:
                summary: Extend by 30 days
                value:
                  expires_in: "720h"
              maximum:
                summary: Maximum allowed (12 months)
                value:
                  expires_in: "8760h"
              oneDay:
                summary: Shorten to 24 hours from now
                value:
                  expires_in: "24h"
      responses:
        "200":
          description: Updated token metadata (same shape as the list endpoint; no raw token)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      site_id:
                        type: string
                        format: uuid
                      label:
                        type: string
                        maxLength: 120
                        nullable: true
                      policy_id:
                        type: string
                        format: uuid
                        nullable: true
                      alert_template_id:
                        type: string
                        format: uuid
                        nullable: true
                      usage_limit:
                        type: integer
                      usage_count:
                        type: integer
                      last_used_at:
                        type: string
                        format: date-time
                        nullable: true
                      expires_at:
                        type: string
                        format: date-time
                      created_at:
                        type: string
                        format: date-time
        "400":
          description: Missing, non-positive, unparseable, or over-cap expires_in
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
  /report-history:
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: List report generation history
      description: Paginated (page/per_page), newest first. Bodies are omitted.
      operationId: listReportHistory
      tags: [Reports]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        "200":
          description: Paginated history list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            template_id: {type: string, format: uuid}
                            format: {type: string}
                            error: {type: string, nullable: true}
                            run_by: {type: string, format: uuid, nullable: true}
                            created_at: {type: string}
                      pagination: {$ref: '#/components/schemas/PaginationMeta'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /report-history/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Get one report history entry (with rendered HTML)
      operationId: getReportHistory
      tags: [Reports]
      responses:
        "200":
          description: History entry
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      template_id: {type: string, format: uuid}
                      report_html: {type: string}
                      format: {type: string}
                      error: {type: string, nullable: true}
                      run_by: {type: string, format: uuid, nullable: true}
                      created_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    delete:
      x-stability: internal
      x-required-scope: reports:write
      summary: Delete a report history entry
      operationId: deleteReportHistory
      tags: [Reports]
      responses:
        "204":
          description: Entry deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /report-history/{id}/download:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Download a generated report
      description: |
        Serves the stored artifact as an attachment — PDF, CSV or HTML
        depending on the entry's format.
      operationId: downloadReportHistory
      tags: [Reports]
      responses:
        "200":
          description: Report attachment
          content:
            application/pdf:
              schema: {type: string, format: binary}
            text/csv:
              schema: {type: string}
            text/html:
              schema: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /report-queries:
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: List report data queries
      operationId: listReportQueries
      tags: [Reports]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated query list, sorted by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &DataQuery
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            name:
                              type: string
                            model:
                              type: string
                              description: Queryable model name (whitelisted server-side)
                            query_definition:
                              type: object
                              description: "Query definition: fields, joins, filters, sort, limit, group_by, aggregations, buckets"
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Create a report data query
      operationId: createReportQuery
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, model, query_definition]
              properties:
                name:
                  type: string
                  maxLength: 255
                model:
                  type: string
                  description: Must be a whitelisted model (see /report-queries/models)
                query_definition:
                  type: object
      responses:
        "201":
          description: Query created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *DataQuery
        "400":
          $ref: "#/components/responses/ValidationError"
  /report-queries/models:
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: List queryable models and operators
      description: |
        Metadata for the query builder — every reportable model with its
        fields and relationships, plus the valid filter operators.
      operationId: listReportQueryModels
      tags: [Reports]
      responses:
        "200":
          description: Model metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      models:
                        type: array
                        items:
                          type: object
                          properties:
                            name: {type: string}
                            table: {type: string}
                            fields:
                              type: array
                              items: {type: string}
                            relationships:
                              type: object
                              additionalProperties:
                                type: object
                                properties:
                                  requires:
                                    type: array
                                    items: {type: string}
                                  fields:
                                    type: array
                                    items: {type: string}
                      operators:
                        type: array
                        items: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /report-queries/test:
    post:
      x-stability: internal
      x-required-scope: reports:read
      summary: Test an unsaved (draft) data query
      description: |
        Executes a draft query definition for the builder preview pane.
        Result set is capped at 10 rows.
      operationId: testDraftReportQuery
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, query_definition]
              properties:
                model: {type: string}
                query_definition:
                  type: object
                  description: Query definition JSON (filters, fields, sort, limit)
      responses:
        "200":
          description: Preview rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      results:
                        type: array
                        items: {type: object}
                      count: {type: integer}
                      model: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /report-queries/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Get a report data query
      operationId: getReportQuery
      tags: [Reports]
      responses:
        "200":
          description: Query details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *DataQuery
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      x-stability: internal
      x-required-scope: reports:write
      summary: Update a report data query
      description: Partial update — only fields present in the body are changed.
      operationId: updateReportQuery
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 255
                model:
                  type: string
                query_definition:
                  type: object
      responses:
        "200":
          description: Query updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *DataQuery
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      x-stability: internal
      x-required-scope: reports:write
      summary: Delete a report data query
      operationId: deleteReportQuery
      tags: [Reports]
      responses:
        "204":
          description: Query deleted
        "404":
          $ref: "#/components/responses/NotFound"
  /report-queries/{id}/duplicate:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Duplicate a saved data query
      description: No request body — the copy is named automatically.
      operationId: duplicateReportQuery
      tags: [Reports]
      responses:
        "200":
          description: The new query
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /report-queries/{id}/test:
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Test a saved data query and return sample results
      description: Executes the saved query with the row limit forced to 10.
      operationId: testReportQuery
      tags: [Reports]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Sample query results (max 10 rows)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      results:
                        type: array
                        items:
                          type: object
                      count:
                        type: integer
                      model:
                        type: string
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
  /report-schedules:
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: List report schedules
      description: Paginated (page/per_page).
      operationId: listReportSchedules
      tags: [Reports]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        "200":
          description: Paginated schedule list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &ReportSchedule
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            template_id: {type: string, format: uuid}
                            format: {type: string}
                            frequency: {type: string}
                            run_time: {type: string}
                            timezone: {type: string}
                            email_recipients:
                              type: array
                              items: {type: string}
                            filter_overrides:
                              type: object
                              nullable: true
                            enabled: {type: boolean}
                            last_run: {type: string, format: date-time, nullable: true}
                            next_run: {type: string, format: date-time, nullable: true}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination: {$ref: '#/components/schemas/PaginationMeta'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Create a report schedule
      operationId: createReportSchedule
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template_id, format, frequency, run_time, timezone, email_recipients]
              properties:
                template_id: {type: string, format: uuid}
                format: {type: string}
                frequency: {type: string}
                run_time: {type: string}
                timezone: {type: string}
                email_recipients:
                  type: array
                  items: {type: string}
                filter_overrides:
                  type: object
                  description: Per-query filter overrides keyed by query name
                enabled: {type: boolean, nullable: true}
      responses:
        "201":
          description: Schedule created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ReportSchedule
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /report-schedules/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Get a report schedule
      operationId: getReportSchedule
      tags: [Reports]
      responses:
        "200":
          description: Schedule
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ReportSchedule
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: reports:write
      summary: Update a report schedule
      description: Partial update — only provided fields change.
      operationId: updateReportSchedule
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                format: {type: string, nullable: true}
                frequency: {type: string, nullable: true}
                run_time: {type: string, nullable: true}
                timezone: {type: string, nullable: true}
                email_recipients:
                  type: array
                  items: {type: string}
                filter_overrides:
                  type: object
                  description: Per-query filter overrides keyed by query name
                enabled: {type: boolean, nullable: true}
      responses:
        "200":
          description: Updated schedule
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ReportSchedule
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    delete:
      x-stability: internal
      x-required-scope: reports:write
      summary: Delete a report schedule
      operationId: deleteReportSchedule
      tags: [Reports]
      responses:
        "204":
          description: Schedule deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /report-schedules/{id}/run-now:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Run a report schedule immediately
      description: |
        Generates the report and emails the schedule's recipients now.
        No request body.
      operationId: runReportScheduleNow
      tags: [Reports]
      responses:
        "200":
          description: Run result
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /report-schedules/{id}/test-send:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Send a test report to one address
      operationId: testSendReportSchedule
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: {type: string, format: email}
      responses:
        "200":
          description: Test sent
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /report-templates:
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: List report templates
      operationId: listReportTemplates
      tags: [Reports]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated template list, sorted by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &ReportTemplateFull
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                            org_id:
                              type: string
                              format: uuid
                            name:
                              type: string
                            description:
                              type: string
                            body:
                              type: string
                            template_type:
                              type: string
                              enum: [markdown, html, plaintext]
                            variables:
                              type: array
                              items:
                                type: object
                            css:
                              type: string
                            created_by:
                              type: string
                              format: uuid
                              nullable: true
                            created_at:
                              type: string
                              format: date-time
                            updated_at:
                              type: string
                              format: date-time
                            built_in:
                              type: boolean
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Create a report template
      operationId: createReportTemplate
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 255
                description:
                  type: string
                body:
                  type: string
                template_type:
                  type: string
                  enum: [markdown, html, plaintext]
                  default: markdown
                variables:
                  type: array
                  items:
                    type: object
                css:
                  type: string
      responses:
        "201":
          description: Template created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ReportTemplateFull
        "400":
          $ref: "#/components/responses/ValidationError"
  /report-templates/import:
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Import a report template from an export envelope
      operationId: importReportTemplate
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema: &TemplateExport
              type: object
              required: [name]
              properties:
                name: {type: string}
                description: {type: string}
                body: {type: string}
                template_type:
                  type: string
                  description: Defaults to markdown when omitted
                variables:
                  type: array
                  items: {type: object}
                css: {type: string}
                exported_at: {type: string, format: date-time}
      responses:
        "201":
          description: Template created
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /report-templates/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Get a report template
      operationId: getReportTemplate
      tags: [Reports]
      responses:
        "200":
          description: Template details with body
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ReportTemplateFull
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      x-stability: internal
      x-required-scope: reports:write
      summary: Update a report template
      description: Partial update — only fields present in the body are changed.
      operationId: updateReportTemplate
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 255
                description:
                  type: string
                body:
                  type: string
                template_type:
                  type: string
                  enum: [markdown, html, plaintext]
                variables:
                  type: array
                  items:
                    type: object
                css:
                  type: string
      responses:
        "200":
          description: Template updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ReportTemplateFull
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      x-stability: internal
      x-required-scope: reports:write
      summary: Delete a report template
      description: Built-in compliance templates cannot be deleted (422).
      operationId: deleteReportTemplate
      tags: [Reports]
      responses:
        "204":
          description: Template deleted
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Built-in compliance template cannot be deleted
  /report-templates/{id}/duplicate:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Duplicate a report template
      description: No request body — the copy is named automatically.
      operationId: duplicateReportTemplate
      tags: [Reports]
      responses:
        "200":
          description: The new template
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /report-templates/{id}/export:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: reports:read
      summary: Export a report template as JSON
      description: Served as a JSON attachment re-importable via /report-templates/import.
      operationId: exportReportTemplate
      tags: [Reports]
      responses:
        "200":
          description: Export envelope attachment
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TemplateExport
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /report-templates/{id}/preview:
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Preview a rendered report template
      description: |
        Renders the template body with the supplied variables. Markdown
        templates are converted to HTML and wrapped in a full document
        with the template CSS; plaintext is returned as-is.
      operationId: previewReportTemplate
      tags: [Reports]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                variables:
                  type: object
                  description: Variable name/value pairs for template rendering
      responses:
        "200":
          description: Rendered output
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      rendered:
                        type: string
                      format:
                        type: string
                        enum: [markdown, html, plaintext]
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          $ref: "#/components/responses/NotFound"
  /report-templates/{id}/preview-data:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: reports:read
      summary: Preview a template with live query data
      description: |
        Renders the template with data resolved from its query variables
        and returns the wrapped HTML. No request body.
      operationId: previewReportTemplateWithData
      tags: [Reports]
      responses:
        "200":
          description: Rendered preview
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      rendered: {type: string}
                      format: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /reports/{templateID}/generate:
    parameters:
      - name: templateID
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: reports:write
      summary: Generate a report from a template
      description: |
        Synchronous generation; the result is also stored in report
        history. Heavy-operation rate limit applies.
      operationId: generateReport
      tags: [Reports]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [format]
              properties:
                format: {type: string}
                filter_overrides:
                  type: object
                  description: Per-query filter overrides keyed by query name
      responses:
        "200":
          description: Generated report history entry
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /retention-policies:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: List data retention policies
      description: Per-table purge configuration for the org.
      operationId: listRetentionPolicies
      tags: [Compliance]
      responses:
        "200":
          description: Policy rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &RetentionPolicy
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        table_name: {type: string}
                        retention_days: {type: integer}
                        enabled: {type: boolean}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /retention-policies/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update a retention policy
      description: Partial update — only non-null fields are applied.
      operationId: updateRetentionPolicy
      tags: [Compliance]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                retention_days: {type: integer, nullable: true}
                enabled: {type: boolean, nullable: true}
      responses:
        "200":
          description: Updated policy
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *RetentionPolicy
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /roles:
    get:
      x-stability: stable
      x-required-scope: roles:read
      summary: List roles
      operationId: listRoles
      tags: [Roles]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated role list, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &RoleRecord
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string, maxLength: 100}
                            permissions:
                              type: object
                              additionalProperties: {type: boolean}
                              description: "JSONB map of permission key -> granted, e.g. {\"agents.list\": true}; keys come from GET /permissions"
                            is_superuser:
                              type: boolean
                              description: Role-level superuser flag — grants every permission
                            created_at: {type: string, format: date-time}
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      x-stability: stable
      x-required-scope: roles:write
      summary: Create a role
      description: Role name must be unique within the org (409 DUPLICATE_NAME).
      operationId: createRole
      tags: [Roles]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string, maxLength: 100}
                permissions:
                  type: object
                  additionalProperties: {type: boolean}
                  description: Permission key -> granted map; omitted = empty map
                is_superuser: {type: boolean, default: false}
      responses:
        "201":
          description: Role created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *RoleRecord
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Role name already exists (DUPLICATE_NAME)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /roles/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: roles:read
      summary: Get a role by ID
      operationId: getRole
      tags: [Roles]
      responses:
        "200":
          description: Role details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *RoleRecord
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      x-stability: stable
      x-required-scope: roles:write
      summary: Update a role
      description: |
        Partial update — omitted fields are untouched; an empty body is
        rejected with 400. A supplied `permissions` map REPLACES the stored
        map wholesale.
      operationId: updateRole
      tags: [Roles]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 100}
                permissions:
                  type: object
                  additionalProperties: {type: boolean}
                is_superuser: {type: boolean}
      responses:
        "200":
          description: Updated role
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *RoleRecord
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Role name already exists (DUPLICATE_NAME)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      x-stability: stable
      x-required-scope: roles:write
      summary: Delete a role
      description: Refused with 409 ROLE_IN_USE while any users are assigned to the role.
      operationId: deleteRole
      tags: [Roles]
      responses:
        "204":
          description: Role deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Role has assigned users (ROLE_IN_USE)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /rustdesk-config/{scopeType}/{scopeID}:
    get:
      x-stability: internal
      x-required-scope: settings:read
      summary: Get the RustDesk config row for a scope
      description: |
        Returns the stored override row for an organisation/client/site/
        agent scope. When no row exists, returns an empty row (all
        override fields null = fully inheriting) rather than 404.
      operationId: getRustDeskConfig
      tags: [Remote Desktop]
      parameters: &RustDeskScopeParams
        - name: scopeType
          in: path
          required: true
          schema:
            type: string
            enum: [org, client, site, agent]
        - name: scopeID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Stored (or empty inheriting) config row
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &RustDeskConfigRow
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      org_id: {type: string, format: uuid}
                      scope_type: {type: string}
                      scope_id: {type: string, format: uuid}
                      enabled: {type: boolean, nullable: true}
                      auto_install: {type: boolean, nullable: true}
                      require_approval: {type: boolean, nullable: true}
                      approval_timeout: {type: integer, nullable: true}
                      timeout_action: {type: string, nullable: true}
                      created_at: {type: string, format: date-time}
                      updated_at: {type: string, format: date-time}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Upsert the RustDesk config for a scope
      description: |
        JSON-Merge-Patch-style per field - an absent key leaves the value
        unchanged, an explicit null clears the override (inherit from a
        broader scope), and a typed value sets it. Every field is
        independently nullable.
      operationId: upsertRustDeskConfig
      tags: [Remote Desktop]
      parameters: *RustDeskScopeParams
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: {type: boolean, nullable: true}
                auto_install: {type: boolean, nullable: true}
                require_approval: {type: boolean, nullable: true}
                approval_timeout: {type: integer, nullable: true}
                timeout_action: {type: string, nullable: true, enum: [deny, allow, null]}
      responses:
        "200":
          description: Stored config row after the upsert
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *RustDeskConfigRow
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
    delete:
      x-stability: internal
      x-required-scope: settings:write
      summary: Delete the RustDesk config row for a scope
      description: Removes the scope's override row entirely so it fully inherits.
      operationId: deleteRustDeskConfig
      tags: [Remote Desktop]
      parameters: *RustDeskScopeParams
      responses:
        "204":
          description: Config row deleted (idempotent)
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
  /script-batches:
    get:
      x-stability: stable
      x-required-scope: scripts:read
      summary: List script batch runs
      description: Paginated batch runs, newest first.
      operationId: listScriptBatches
      tags: [Scripts]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        "200":
          description: Paginated batch list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &ScriptBatch
                          type: object
                          description: One row from the script_run_batches table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            script_id: {type: string, format: uuid}
                            user_id: {type: string, format: uuid, nullable: true}
                            script_name: {type: string}
                            shell_type: {type: string}
                            agent_count: {type: integer}
                            completed_count: {type: integer}
                            failed_count: {type: integer}
                            queued_count: {type: integer}
                            cancelled_count: {type: integer}
                            status:
                              type: string
                              enum: [running, completed]
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination: &ScriptPaginationBlock
                        type: object
                        properties:
                          page: {type: integer}
                          per_page: {type: integer}
                          total: {type: integer}
                          total_pages: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /script-batches/{batchID}:
    parameters:
      - name: batchID
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: scripts:read
      summary: Get batch detail with per-agent jobs
      operationId: getScriptBatch
      tags: [Scripts]
      responses:
        "200":
          description: Batch header and jobs
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch: *ScriptBatch
                      jobs:
                        type: array
                        items:
                          type: object
                          description: One row from the script_run_batch_jobs table
                          properties:
                            id: {type: string, format: uuid}
                            agent_id: {type: string, format: uuid}
                            agent_hostname: {type: string}
                            status:
                              type: string
                              enum: [pending, running, queued_offline, completed, failed, cancelled]
                            stdout: {type: string, nullable: true}
                            stderr: {type: string, nullable: true}
                            retcode: {type: integer, nullable: true}
                            execution_time: {type: number, nullable: true}
                            pending_cmd_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Set while queued in pending_agent_commands for an offline agent
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: scripts:execute
      summary: Cancel a batch
      description: |
        Cancels every pending and queued_offline job in the batch (running
        or finished jobs are untouched) and recomputes the batch counters
        and status.
      operationId: cancelScriptBatch
      tags: [Scripts]
      responses:
        "200":
          description: Pending/queued jobs cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      cancelled:
                        type: integer
                        description: Number of jobs transitioned to cancelled
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /script-batches/{batchID}/jobs/{jobID}:
    delete:
      x-stability: stable
      x-required-scope: scripts:execute
      summary: Cancel a single batch job
      description: |
        Cancels one pending or queued_offline job (removing its queued
        offline command if present). Jobs in any other state return 409
        INVALID_STATE.
      operationId: cancelScriptBatchJob
      tags: [Scripts]
      parameters:
        - name: batchID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: jobID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Job cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [cancelled]
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Job is not in a cancellable state (INVALID_STATE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /script-categories:
    get:
      x-stability: stable
      x-required-scope: scripts:read
      summary: List script categories
      description: |
        Flat list ordered by name, or a nested tree when `?format=tree`
        (tree nodes add a recursive `children` array). Not paginated.
      operationId: listScriptCategories
      tags: [Scripts]
      parameters:
        - name: format
          in: query
          schema:
            type: string
            enum: [tree]
      responses:
        "200":
          description: Category list (flat or tree)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &ScriptCategory
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        name: {type: string, maxLength: 100}
                        parent_id: {type: string, format: uuid, nullable: true}
                        created_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: scripts:write
      summary: Create a script category
      operationId: createScriptCategory
      tags: [Scripts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: {type: string, maxLength: 100}
                parent_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Optional parent for nesting
      responses:
        "201":
          description: Category created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ScriptCategory
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /script-categories/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    put:
      x-stability: stable
      x-required-scope: scripts:write
      summary: Update a script category
      operationId: updateScriptCategory
      tags: [Scripts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 100}
                parent_id: {type: string, format: uuid, nullable: true}
      responses:
        "200":
          description: Category updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ScriptCategory
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: scripts:write
      summary: Delete a script category
      operationId: deleteScriptCategory
      tags: [Scripts]
      responses:
        "204":
          description: Category deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /script-runs/{executionID}/status:
    get:
      x-stability: stable
      x-required-scope: scripts:execute
      summary: Poll async script run status
      description: |
        Polling endpoint for runs dispatched with async:true (added
        2026-06-03 alongside async execution). Returns accumulated output
        from the in-memory run store. `status` is `running` until the
        agent's final reply lands, then `complete` with the authoritative
        stdout/stderr/retcode. Unknown or expired execution IDs return
        `{"status":"unknown"}` with 200 — stop polling and read run
        history instead. Run state is held in memory with a TTL; actively
        polled runs are kept alive.
      operationId: scriptRunStatus
      tags: [Scripts]
      parameters:
        - name: executionID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Run status and accumulated output
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [running, complete, unknown]
                      output:
                        type: string
                        description: Accumulated stdout (absent for unknown runs)
                      stderr: {type: string}
                      retcode:
                        type: integer
                        nullable: true
                        description: Null until complete; -1 marks dispatch/timeout failure
                      execution_time:
                        type: number
                        nullable: true
                        description: Seconds, set once complete
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /script-runs/{executionID}/stream:
    get:
      x-stability: stable
      x-required-scope: scripts:execute
      summary: Stream script output via SSE
      description: |
        Server-Sent Events relay of live script output for an async run.
        The run must already exist (pre-registered at dispatch) — unknown
        IDs receive an `event: error` frame and the stream closes. Frames
        are JSON deltas {line | stderr | done+retcode}; a late connector
        receives the full cached output first. Heartbeats keep the
        connection alive and the stream is hard-capped server-side.
      operationId: scriptOutputStream
      tags: [Scripts]
      parameters:
        - name: executionID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: SSE event stream of script output
          content:
            text/event-stream:
              schema:
                type: string
                description: |
                  data frames are JSON: {"line":"..."} for stdout,
                  {"stderr":"..."} for stderr, {"done":true,"retcode":N}
                  on completion.
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /scripts:
    get:
      x-stability: stable
      x-required-scope: scripts:read
      summary: List scripts
      description: |
        Paginated script list ordered by name. Bodies are omitted — fetch
        GET /scripts/{id} for the body. Invalid category_id values are
        ignored rather than rejected.
      operationId: listScripts
      tags: [Scripts]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: category_id
          in: query
          schema: {type: string, format: uuid}
        - name: shell_type
          in: query
          schema:
            type: string
            enum: [powershell, cmd, bash, python, nushell]
        - name: search
          in: query
          description: Case-insensitive substring match on name or description
          schema: {type: string}
        - name: is_snippet
          in: query
          schema: {type: boolean}
        - name: os
          in: query
          description: Only scripts whose supported_os contains this value
          schema:
            type: string
            enum: [windows, linux, darwin]
      responses:
        "200":
          description: Paginated script list (without bodies)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &ScriptCore
                          type: object
                          description: Script metadata (body omitted in lists)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            name: {type: string, maxLength: 255}
                            description: {type: string, nullable: true}
                            shell_type:
                              type: string
                              enum: [powershell, cmd, bash, python, nushell]
                            category_id: {type: string, format: uuid, nullable: true}
                            default_args:
                              type: array
                              items: {type: string}
                            default_env_vars:
                              type: array
                              description: KEY=VALUE strings
                              items: {type: string}
                            default_timeout:
                              type: integer
                              minimum: 1
                              description: Seconds
                            is_snippet:
                              type: boolean
                              description: Snippets are included into other scripts, not run directly
                            supported_os:
                              type: array
                              items:
                                type: string
                                enum: [windows, linux, darwin]
                            output_field_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Custom field that successful run output is written back to
                            output_parser:
                              type: string
                              nullable: true
                              description: "raw, trim, json_path:<path> or regex:<pattern>"
                            created_by: {type: string, format: uuid, nullable: true}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination: *ScriptPaginationBlock
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: scripts:write
      summary: Create a script
      description: |
        output_parser requires output_field_id. supported_os values are
        limited to windows/linux/darwin.
      operationId: createScript
      tags: [Scripts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, shell_type, body]
              properties:
                name: {type: string, maxLength: 255}
                description: {type: string}
                shell_type:
                  type: string
                  enum: [powershell, cmd, bash, python, nushell]
                body: {type: string}
                category_id: {type: string, format: uuid, nullable: true}
                default_args:
                  type: array
                  items: {type: string}
                default_env_vars:
                  type: array
                  items: {type: string}
                default_timeout: {type: integer, minimum: 1}
                is_snippet: {type: boolean, default: false}
                supported_os:
                  type: array
                  items:
                    type: string
                    enum: [windows, linux, darwin]
                output_field_id: {type: string, format: uuid, nullable: true}
                output_parser:
                  type: string
                  description: "raw, trim, json_path:<path> or regex:<pattern>"
      responses:
        "201":
          description: Script created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - *ScriptCore
                      - type: object
                        properties:
                          body: {type: string}
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /scripts/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: scripts:read
      summary: Get a script by ID
      operationId: getScript
      tags: [Scripts]
      responses:
        "200":
          description: Script details including body
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - *ScriptCore
                      - type: object
                        properties:
                          body: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: stable
      x-required-scope: scripts:write
      summary: Update a script
      description: |
        Partial update — only fields present are changed. output_field_id
        and output_parser distinguish absent (unchanged) from null
        (explicitly cleared).
      operationId: updateScript
      tags: [Scripts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
                description: {type: string}
                shell_type:
                  type: string
                  enum: [powershell, cmd, bash, python, nushell]
                body: {type: string}
                category_id: {type: string, format: uuid, nullable: true}
                default_args:
                  type: array
                  items: {type: string}
                default_env_vars:
                  type: array
                  items: {type: string}
                default_timeout: {type: integer, minimum: 1}
                is_snippet: {type: boolean}
                supported_os:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum: [windows, linux, darwin]
                output_field_id: {type: string, format: uuid, nullable: true}
                output_parser: {type: string, nullable: true}
      responses:
        "200":
          description: Script updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - *ScriptCore
                      - type: object
                        properties:
                          body: {type: string}
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: scripts:write
      summary: Delete a script
      operationId: deleteScript
      tags: [Scripts]
      responses:
        "204":
          description: Script deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /scripts/{scriptID}/dispatch:
    post:
      x-stability: stable
      x-required-scope: scripts:execute
      summary: Dispatch a script to multiple agents as a durable batch
      description: |
        Creates a batch and enqueues one durable River job per agent
        (survives page navigation and API restarts; offline agents are
        queued for reconnect). Returns the batch_id immediately — track
        progress via GET /script-batches/{batchID}. Unknown agent IDs are
        silently skipped; 400 if none resolve.
      operationId: dispatchScriptBatch
      tags: [Scripts]
      parameters:
        - name: scriptID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_ids]
              properties:
                agent_ids:
                  type: array
                  minItems: 1
                  items: {type: string, format: uuid}
                args:
                  type: array
                  items: {type: string}
                env_vars:
                  type: array
                  items: {type: string}
                timeout:
                  type: integer
                  minimum: 1
                  maximum: 3600
      responses:
        "202":
          description: Batch created and jobs enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      batch_id: {type: string, format: uuid}
                      agents:
                        type: integer
                        description: Agents resolved in this tenant
                      enqueued:
                        type: integer
                        description: River jobs successfully enqueued
        "400":
          description: Missing agent_ids, no valid agents, or snippet error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /scripts/{scriptID}/history:
    get:
      x-stability: stable
      x-required-scope: scripts:read
      summary: Get execution history for a script
      description: |
        Paginated run history, newest first, with the agent hostname
        joined in. Invalid agent_id/from/to values are ignored rather
        than rejected.
      operationId: scriptHistory
      tags: [Scripts]
      parameters:
        - name: scriptID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: agent_id
          in: query
          schema: {type: string, format: uuid}
        - name: from
          in: query
          description: RFC3339 lower bound on created_at
          schema: {type: string, format: date-time}
        - name: to
          in: query
          description: RFC3339 upper bound on created_at
          schema: {type: string, format: date-time}
      responses:
        "200":
          description: Paginated execution history
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          description: One row from the script_run_history table
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            script_id: {type: string, format: uuid, nullable: true}
                            agent_id: {type: string, format: uuid, nullable: true}
                            agent_hostname:
                              type: string
                              nullable: true
                              description: Null when the agent has been deleted
                            user_id: {type: string, format: uuid, nullable: true}
                            stdout: {type: string, nullable: true}
                            stderr: {type: string, nullable: true}
                            retcode:
                              type: integer
                              nullable: true
                              description: -1 marks a synthetic failure row (timeout / decode error)
                            execution_time: {type: number, nullable: true}
                            created_at: {type: string, format: date-time}
                      pagination: *ScriptPaginationBlock
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /scripts/{scriptID}/run:
    post:
      x-stability: stable
      x-required-scope: scripts:execute
      summary: Bulk-run a script on multiple agents
      description: |
        Fire-and-forget fan-out over NATS: returns dispatch counts
        immediately while per-agent results (including per-agent timeout
        failures) are recorded to run history in the background. Unknown
        agent IDs are silently skipped. In-memory only — for a durable,
        trackable, cancellable fan-out use POST
        /scripts/{scriptID}/dispatch instead.
      operationId: bulkRunScript
      tags: [Scripts]
      parameters:
        - name: scriptID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_ids]
              properties:
                agent_ids:
                  type: array
                  minItems: 1
                  items: {type: string, format: uuid}
                args:
                  type: array
                  items: {type: string}
                env_vars:
                  type: array
                  items: {type: string}
                timeout:
                  type: integer
                  minimum: 1
                  maximum: 3600
      responses:
        "200":
          description: Dispatch summary (results land in run history asynchronously)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total:
                        type: integer
                        description: Agent IDs supplied in the request
                      dispatched:
                        type: integer
                        description: Agents the command was actually sent to
        "400":
          description: Validation failure or snippet error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /settings/agent-power:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: Get agent power action messages
      description: |
        End-user notification texts shown before reboot/shutdown actions.
        Read and write both require settings-manage.
      operationId: getAgentPowerSettings
      tags: [Agent Operations]
      responses:
        "200":
          description: Message settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &AgentPowerSettings
                    type: object
                    properties:
                      reboot_message: {type: string}
                      shutdown_message: {type: string}
                      scheduled_reboot_message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update agent power action messages
      operationId: updateAgentPowerSettings
      tags: [Agent Operations]
      requestBody:
        required: true
        content:
          application/json:
            schema: *AgentPowerSettings
      responses:
        "200":
          description: Updated settings (request echoed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *AgentPowerSettings
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /settings/ai:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: Get AI script-generation settings
      description: The API key is returned masked; api_key_set reports presence.
      operationId: getAiSettings
      tags: [AI]
      responses:
        "200":
          description: AI settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      api_key_set: {type: boolean}
                      api_key:
                        type: string
                        description: Masked for display
                      model: {type: string, example: gpt-4o}
                      enabled: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update AI script-generation settings
      description: Partial update — only non-null fields are applied.
      operationId: updateAiSettings
      tags: [AI]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                api_key: {type: string, nullable: true}
                model: {type: string, nullable: true}
                enabled: {type: boolean, nullable: true}
      responses:
        "200":
          description: Settings updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /settings/locked-accounts:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: List locked accounts
      description: |
        Accounts currently locked by brute-force protection. Lockout state is
        held in memory on the API process — it resets on restart and is not
        shared across replicas.
      operationId: listLockedAccounts
      tags: [Security]
      responses:
        "200":
          description: Currently locked accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        username: {type: string}
                        locked_until: {type: string, format: date-time}
                        attempts:
                          type: integer
                          description: Failed attempts recorded against the identifier
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /settings/locked-accounts/{username}/unlock:
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Unlock a locked account
      description: |
        Clears the in-memory lockout for the username. Idempotent — unlocking
        an account that is not locked still returns 200.
      operationId: unlockAccount
      tags: [Security]
      parameters:
        - name: username
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Account unlocked
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string, example: account unlocked}
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /settings/notifications:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: Get notification settings (SMTP, Twilio, Slack, Teams, Discord)
      description: |
        Aggregate channel configuration. Secrets are masked: SMTP password
        and Twilio auth token come back as `********`, webhook URLs are
        partially masked.
      operationId: getNotificationSettings
      tags: [Notifications]
      responses:
        "200":
          description: Notification channel configuration (secrets masked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      smtp: &SMTPConfigMasked
                        type: object
                        properties:
                          host:
                            type: string
                          port:
                            type: integer
                            default: 587
                          user:
                            type: string
                          password:
                            type: string
                            description: Masked ("********") when set
                          from:
                            type: string
                            format: email
                          tls:
                            type: boolean
                            default: true
                      twilio:
                        type: object
                        properties:
                          account_sid:
                            type: string
                          auth_token:
                            type: string
                            description: Masked ("********") when set
                          from_number:
                            type: string
                      slack:
                        type: object
                        properties:
                          default_webhook_url:
                            type: string
                            description: Masked when set
                      teams:
                        type: object
                        properties:
                          default_webhook_url:
                            type: string
                            description: Masked when set
                      discord:
                        type: object
                        properties:
                          default_webhook_url:
                            type: string
                            description: Masked when set
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update channel webhook defaults (Slack, Teams, Discord)
      description: |
        Updates the org-default webhook URLs. Sending back a masked value
        (prefix `********`) preserves the stored URL instead of
        overwriting it. SMTP is updated via PUT /settings/smtp, not here.
      operationId: updateNotificationSettings
      tags: [Notifications]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                slack:
                  type: object
                  properties:
                    default_webhook_url:
                      type: string
                teams:
                  type: object
                  properties:
                    default_webhook_url:
                      type: string
                discord:
                  type: object
                  properties:
                    default_webhook_url:
                      type: string
      responses:
        "200":
          description: Settings updated (echoes the request payload)
        "400":
          $ref: "#/components/responses/ValidationError"
  /settings/notifications/test/discord:
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Send a test notification to the default Discord webhook
      operationId: testDiscordNotification
      tags: [Notifications]
      responses:
        "200":
          description: "Test notification sent ({status: sent})"
        "400":
          description: Discord webhook URL not configured
        "502":
          description: Discord delivery failed
  /settings/notifications/test/slack:
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Send a test notification to the default Slack webhook
      operationId: testSlackNotification
      tags: [Notifications]
      responses:
        "200":
          description: "Test notification sent ({status: sent})"
        "400":
          description: Slack webhook URL not configured
        "502":
          description: Slack delivery failed
  /settings/notifications/test/teams:
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Send a test notification to the default Teams webhook
      operationId: testTeamsNotification
      tags: [Notifications]
      responses:
        "200":
          description: "Test notification sent ({status: sent})"
        "400":
          description: Teams webhook URL not configured
        "502":
          description: Teams delivery failed
  /settings/portal-branding:
    get:
      x-stability: internal
      x-required-scope: settings:write
      operationId: getPortalBrandingSettings
      summary: Get portal branding settings (MSP admin)
      description: |
        MSP-side branding management read. Returns the full
        client_portal_settings row for the tenant including the logo B2
        key, white-label CSAT fields and document storage usage —
        a superset of what GET /portal/branding exposes to contacts.

        Requires an MSP staff user on a PSA or Bundle tier tenant with
        the settings.manage permission (note: the read also requires the
        write-level scope).
      tags: [Portal Branding]
      responses:
        "200":
          description: Branding settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &BrandingSettings
                    type: object
                    properties:
                      logo_url:
                        type: string
                        nullable: true
                        description: Presigned 1h download URL; null when no logo or storage unconfigured
                      logo_b2_key: {type: string, nullable: true}
                      primary_color: {type: string, nullable: true}
                      secondary_color: {type: string, nullable: true}
                      footer_text: {type: string, nullable: true}
                      privacy_url: {type: string, nullable: true}
                      terms_url: {type: string, nullable: true}
                      business_name:
                        type: string
                        nullable: true
                        description: White-label CSAT sign-off name; null falls back to the org name
                      google_review_url:
                        type: string
                        nullable: true
                        description: 5-star CSAT review CTA target; null hides the CTA
                      storage_bytes_used: {type: integer, format: int64}
                      feature_gates: *PortalFeatureGates
              example:
                data: &BrandingSettingsExample
                  logo_url: null
                  logo_b2_key: null
                  primary_color: "#0B6CF2"
                  secondary_color: null
                  footer_text: "Acme Managed Services — support@acmemanaged.example"
                  privacy_url: "https://www.acmemanaged.example/privacy"
                  terms_url: null
                  business_name: "Acme Managed Services"
                  google_review_url: null
                  storage_bytes_used: 524288000
                  feature_gates:
                    show_billing_section: true
                    show_kb_section: true
                    show_documents_section: true
                    allow_ticket_creation: true
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: internal
      x-required-scope: settings:write
      operationId: updatePortalBrandingSettings
      summary: Update portal branding settings (MSP admin)
      description: |
        Partial-merge update: omitted boolean gates and string fields
        keep their current values; supplied strings overwrite (an
        explicit empty/whitespace string clears the value back to NULL).
        Colours must be valid hex and `primary_color` must meet WCAG
        3.0:1 contrast against white — violations return 422 with a bare
        `{"error": "invalid_hex"|"low_contrast", "field": ...}` body
        (NOT the standard error envelope). `google_review_url` must be
        http(s) when non-empty (422 invalid_url). Responds with the full
        post-update settings.

        Requires an MSP staff user on a PSA or Bundle tier tenant with
        the settings.manage permission.
      tags: [Portal Branding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                primary_color: {type: string, nullable: true}
                secondary_color: {type: string, nullable: true}
                footer_text: {type: string, nullable: true}
                privacy_url: {type: string, nullable: true}
                terms_url: {type: string, nullable: true}
                show_billing_section: {type: boolean, nullable: true}
                show_kb_section: {type: boolean, nullable: true}
                show_documents_section: {type: boolean, nullable: true}
                allow_ticket_creation: {type: boolean, nullable: true}
                logo_b2_key:
                  type: string
                  nullable: true
                  description: Set after a presigned logo upload completes
                business_name:
                  type: string
                  nullable: true
                  description: Empty string clears (falls back to org name)
                google_review_url:
                  type: string
                  nullable: true
                  description: Empty string clears (hides the review CTA)
            example:
              primary_color: "#0B6CF2"
              footer_text: "Acme Managed Services — support@acmemanaged.example"
              show_documents_section: false
      responses:
        "200":
          description: Updated branding settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *BrandingSettings
              example:
                data: *BrandingSettingsExample
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "422":
          description: "Validation failure: invalid_hex, low_contrast or invalid_url. Body is a bare {error, field} object, not the standard envelope."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum: [invalid_hex, low_contrast, invalid_url]
                  field: {type: string, example: primary_color}
        "429": {$ref: '#/components/responses/RateLimited'}
  /settings/portal-branding/logo:
    post:
      x-stability: internal
      x-required-scope: settings:write
      operationId: presignPortalBrandingLogo
      summary: Presign a portal logo upload (MSP admin)
      description: |
        Returns a 15-minute presigned B2 PUT URL and the generated
        object key for a portal logo. `mime_type` must be `image/*`.
        After uploading, persist the key via
        PUT /settings/portal-branding with `logo_b2_key`.

        Requires an MSP staff user on a PSA or Bundle tier tenant with
        the settings.manage permission.
      tags: [Portal Branding]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                filename: {type: string}
                mime_type: {type: string, example: image/png}
      responses:
        "200":
          description: Presigned upload URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      upload_url: {type: string}
                      b2_key: {type: string, example: "logos/7f1c2b3a-4d5e-6f70-8192-a3b4c5d6e7f8.png"}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
        "503":
          description: Object storage not configured (STORAGE_UNAVAILABLE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
    delete:
      x-stability: internal
      x-required-scope: settings:write
      operationId: deletePortalBrandingLogo
      summary: Clear the portal logo (MSP admin)
      description: |
        Clears `logo_b2_key` on the tenant's branding row (the stored
        object itself is not deleted from B2). All other branding fields
        are preserved.

        Requires an MSP staff user on a PSA or Bundle tier tenant with
        the settings.manage permission.
      tags: [Portal Branding]
      responses:
        "204":
          description: Logo cleared
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /settings/rustdesk:
    get:
      x-stability: internal
      x-required-scope: settings:read
      summary: Get RustDesk server settings and org approval defaults
      operationId: getRustDeskSettings
      tags: [Remote Desktop]
      responses:
        "200":
          description: Server config + org-level approval defaults
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      server_url: {type: string}
                      public_key: {type: string}
                      configured:
                        type: boolean
                        description: True when the server public key is present
                      require_approval: {type: boolean}
                      approval_timeout: {type: integer}
                      timeout_action: {type: string, enum: [deny, allow]}
        "401": {$ref: '#/components/responses/Unauthorized'}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update the org's RustDesk approval defaults
      description: |
        Updates org-level require-approval settings. `approval_timeout` is
        clamped to 10-300 seconds; `timeout_action` falls back to "deny"
        for any value other than "allow". Returns the (normalised) values
        that were stored.
      operationId: updateRustDeskSettings
      tags: [Remote Desktop]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                require_approval: {type: boolean}
                approval_timeout: {type: integer, minimum: 10, maximum: 300}
                timeout_action: {type: string, enum: [deny, allow]}
      responses:
        "200":
          description: Stored approval settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      require_approval: {type: boolean}
                      approval_timeout: {type: integer}
                      timeout_action: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
  /settings/security:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: Get security settings
      description: Org-level session timeout and concurrent-session limit, enforced by the auth middleware.
      operationId: getSecuritySettings
      tags: [Security]
      responses:
        "200":
          description: Current security settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: &SecuritySettings
                    type: object
                    properties:
                      session_timeout_minutes:
                        type: integer
                        minimum: 5
                        maximum: 43200
                        description: Idle session lifetime in minutes (max 30 days)
                      max_concurrent_sessions:
                        type: integer
                        minimum: 1
                        maximum: 100
                        description: Sessions allowed per user; the oldest is evicted at login when the cap is hit
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update security settings
      description: |
        Both fields are required (whole-object replace).
        `session_timeout_minutes` 5–43200; `max_concurrent_sessions` 1–100.
      operationId: updateSecuritySettings
      tags: [Security]
      requestBody:
        required: true
        content:
          application/json:
            schema: *SecuritySettings
      responses:
        "200":
          description: Settings updated (echoes the stored values)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SecuritySettings
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /settings/signing-key:
    post:
      x-stability: internal
      summary: Generate an Ed25519 signing key pair
      description: |
        Creates the organisation's Ed25519 key pair for agent binary
        signing. One key per org — 409 if one already exists. The private
        key is AES-256-GCM encrypted at rest and never returned. Platform
        admin only.
      operationId: generateSigningKey
      tags: [CodeSigning]
      responses:
        "201":
          description: Signing key generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      public_key:
                        type: string
                        description: Hex-encoded Ed25519 public key
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Signing key already exists for this organisation
    get:
      x-stability: internal
      summary: Get signing key status (public key only)
      operationId: getSigningKey
      tags: [CodeSigning]
      responses:
        "200":
          description: Hex-encoded public key (private key never exposed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      public_key:
                        type: string
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No signing key configured for this organisation
  /settings/smtp:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: Get SMTP configuration
      description: Password is masked ("********") when set.
      operationId: getSMTP
      tags: [Notifications]
      responses:
        "200":
          description: SMTP settings (password masked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SMTPConfigMasked
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update SMTP configuration
      description: |
        `host`, a valid `port`, and `from` are required. Field names are
        `user`/`from` (not `username`/`from_address`).
      operationId: updateSMTP
      tags: [Notifications]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [host, port, from]
              properties:
                host:
                  type: string
                port:
                  type: integer
                  minimum: 1
                  maximum: 65535
                user:
                  type: string
                password:
                  type: string
                from:
                  type: string
                  format: email
                tls:
                  type: boolean
      responses:
        "200":
          description: SMTP settings updated (password masked in response)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SMTPConfigMasked
        "400":
          $ref: "#/components/responses/ValidationError"
  /settings/smtp/test:
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Send a test email via SMTP
      operationId: testSMTP
      tags: [Notifications]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to:
                  type: string
                  format: email
      responses:
        "200":
          description: "Test email sent ({status: sent, to})"
        "400":
          description: SMTP not configured or invalid request
        "502":
          description: SMTP delivery failed
  /settings/threatdown:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: Get org ThreatDown (OneView) settings
      description: Client ID is returned masked; the secret is never returned.
      operationId: getThreatdownSettings
      tags: [ThreatDown]
      responses:
        "200":
          description: Settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      enabled: {type: boolean}
                      client_id_set: {type: boolean}
                      has_creds: {type: boolean}
                      installer_url: {type: string}
                      webhook_path: {type: string}
                      default_subscription:
                        type: object
                        nullable: true
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update org ThreatDown settings
      description: |
        Partial update of OneView credentials, installer URL and the
        default subscription applied to newly created sites.
      operationId: updateThreatdownSettings
      tags: [ThreatDown]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: {type: boolean, nullable: true}
                client_id: {type: string, nullable: true}
                client_secret: {type: string, nullable: true}
                installer_url: {type: string, nullable: true}
                default_subscription: *TDSubscriptionRequest
                clear_default_subscription: {type: boolean}
      responses:
        "200":
          description: Settings updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /signup:
    post:
      x-stability: internal
      summary: Start a self-serve signup
      description: |
        Creates a pending signup and emails a magic verification link (24h
        TTL). Requires a Cloudflare Turnstile token (always-on for this
        route). Sets the `opsmerge_signup_session` cookie used by GET
        /signup/verify for same-device binding. Rate limit: 3 per IP per hour.

        Beta invite codes: while signups are paused (closed beta), a valid
        `beta_code` bypasses the pause, forces the crack_on tier, and the
        tenant is provisioned comped in the beta_tester cohort. Without a
        code, paused signups get 403 SIGNUPS_PAUSED.
      operationId: signupCreate
      security: []
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, slug, company_name, cf_turnstile_response]
              properties:
                email: {type: string, format: email}
                password: {type: string}
                slug: {type: string, description: Desired tenant slug (checked via GET /signup/check-slug)}
                company_name: {type: string}
                phone: {type: string}
                country: {type: string, description: ISO country code used for phone E.164 normalisation}
                billing_period:
                  type: string
                  enum: [monthly, annual]
                  description: Optional for the free tier (defaults to monthly)
                selected_tier:
                  type: string
                  enum: [free, ease_in, crack_on]
                  description: Omitted = free
                wizard_answers:
                  type: object
                  description: Tier-recommendation wizard answers
                  properties:
                    q1_uses_rmm: {type: string, enum: ["yes", "no"]}
                    q2_bills_clients: {type: string, enum: ["yes", "no"]}
                    q3_endpoint_range: {type: string, enum: ["<50", "50-250", "250-1000", "1000+"]}
                    q4_tech_team_size: {type: string, enum: ["1-3", "4-10", "10-25", "25+"]}
                    q5_uses_psa: {type: string, enum: ["yes", "no", not_sure]}
                beta_code:
                  type: string
                  description: Warm-invite code; bypasses the closed-beta pause
                cf_turnstile_response:
                  type: string
                  description: Cloudflare Turnstile token (always required here)
      responses:
        "202":
          description: Signup created; verification email dispatched. Sets the opsmerge_signup_session cookie
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      signup_id: {type: string, format: uuid}
                      verification_email_sent_at: {type: string, format: date-time}
        "400":
          description: "Field validation (VALIDATION with errors map), Turnstile rejected (BOT_CHECK_FAILED), or unknown beta code (BETA_CODE_UNKNOWN)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Signups paused for closed beta and no beta_code supplied (SIGNUPS_PAUSED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: "Email already has an account (USERNAME_TAKEN), slug taken (SLUG_TAKEN), or email has a pending signup (EMAIL_PENDING)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "410":
          description: Beta code already used (BETA_CODE_REDEEMED) or expired (BETA_CODE_EXPIRED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Turnstile verifier unreachable/misconfigured (BOT_CHECK_UNAVAILABLE) — retry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /signup/check-slug:
    get:
      x-stability: internal
      summary: Check tenant slug availability
      operationId: signupCheckSlug
      security: []
      tags: [Auth]
      parameters:
        - name: slug
          in: query
          required: true
          schema: {type: string}
      responses:
        "200":
          description: Availability result (always 200; reason explains a false)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      available: {type: boolean}
                      reason:
                        type: string
                        enum: [format, taken]
                        description: Present only when available=false
  /signup/provision/status/{token}:
    get:
      x-stability: internal
      summary: Poll tenant provisioning status
      description: |
        Polled by the provisioning page after /signup/verify. The path
        parameter is named `token` for historical routing reasons but the
        value is the signup UUID returned by /signup/verify as
        `provisioning_id`.
      operationId: signupProvisionStatus
      security: []
      tags: [Auth]
      parameters:
        - name: token
          in: path
          required: true
          description: Signup UUID (the provisioning_id from /signup/verify)
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Current provisioning state
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [pending, verifying, provisioning, ready, failed]
                      org_id:
                        type: string
                        format: uuid
                        description: Present once provisioned
                      error:
                        type: string
                        description: Present when status=failed
        "400":
          description: Path value is not a UUID (BAD_ID)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"
  /signup/resend:
    post:
      x-stability: internal
      summary: Resend the signup verification email
      description: |
        Throttled resend — maximum 5 resends with a 60-second cooldown.
        Resending also extends the token expiry. Shares the 3/hour signup
        rate-limit bucket.
      operationId: signupResend
      security: []
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signup_id]
              properties:
                signup_id: {type: string, format: uuid}
      responses:
        "204":
          description: Verification email re-sent
        "400":
          description: Malformed body or signup_id (BAD_JSON / BAD_ID)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Signups paused and this signup has no beta invite (SIGNUPS_PAUSED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          description: Resend cap reached (RESEND_MAX) or 60s cooldown active (RESEND_THROTTLED) — also the shared signup IP limit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /signup/verify:
    get:
      x-stability: internal
      summary: Consume the signup magic link
      description: |
        Verifies the emailed token and enqueues tenant provisioning.
        Same-device binding: the request must carry the
        `opsmerge_signup_session` cookie set by POST /signup, otherwise 401
        DEVICE_MISMATCH. Idempotent for already-provisioned signups (returns
        status=ready), including magic-link re-clicks after the token was
        consumed.
      operationId: signupVerify
      security: []
      tags: [Auth]
      parameters:
        - name: token
          in: query
          required: true
          description: Verification token from the magic-link email
          schema: {type: string}
      responses:
        "200":
          description: Verified — provisioning enqueued (status=provisioning) or already complete (status=ready with org_id)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      provisioning_id:
                        type: string
                        format: uuid
                        description: Poll /signup/provision/status/{provisioning_id}
                      status:
                        type: string
                        enum: [provisioning, ready]
                      org_id:
                        type: string
                        format: uuid
                        description: Present when status=ready
        "400":
          description: Missing token (NO_TOKEN)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Signup session cookie missing — open the link on the signup device (DEVICE_MISMATCH)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "410":
          description: Token invalid or expired (TOKEN_INVALID)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /sites:
    get:
      x-stability: stable
      x-required-scope: sites:read
      summary: List all sites across all clients
      description: |
        Paginated list of every site in the tenant with `client_name`
        hydrated, ordered by client name then site name. Used to populate
        site pickers (e.g. the installer page).
      operationId: listSites
      tags: [Sites]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated site list with client names
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: *Site
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /sites/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: sites:read
      summary: Get a site by ID
      operationId: getSite
      tags: [Sites]
      responses:
        "200":
          description: Site details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Site
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      x-stability: stable
      x-required-scope: sites:write
      summary: Update a site
      description: Only the name is updatable; uniqueness is enforced within the parent client.
      operationId: updateSite
      tags: [Sites]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
      responses:
        "200":
          description: Updated site
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Site
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Site name already exists under this client (DUPLICATE_NAME)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      x-stability: stable
      x-required-scope: sites:write
      summary: Delete a site
      description: |
        Refused with 409 SITE_HAS_AGENTS while agents are assigned. Deleting a
        site also removes its registration tokens and document associations.
      operationId: deleteSite
      tags: [Sites]
      responses:
        "204":
          description: Site deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Site has assigned agents (SITE_HAS_AGENTS)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /sites/{id}/alert-template:
    put:
      x-stability: stable
      x-required-scope: sites:write
      summary: Assign or clear the site's alert template
      description: |
        Sets `alert_template_id` on the site; `null` clears the assignment so
        the site inherits from its client or the org default.
      operationId: assignSiteAlertTemplate
      tags: [Sites]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [alert_template_id]
              properties:
                alert_template_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Template to assign, or null to clear
      responses:
        "200":
          description: Updated site
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Site
        "400":
          description: Invalid body, or alert_template_id not found in this tenant (INVALID_ASSIGNMENT)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /sites/{id}/custom-fields:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: internal
      x-required-scope: sites:read
      summary: Get custom field values for a site
      operationId: getSiteCustomFields
      tags: [CustomFields]
      responses:
        "200": *CustomFieldValueList
    put:
      x-stability: internal
      x-required-scope: sites:write
      summary: Set custom field values for a site
      operationId: setSiteCustomFields
      tags: [CustomFields]
      requestBody: *CustomFieldValuesUpdate
      responses:
        "200": *CustomFieldValuesUpdated
        "400":
          $ref: "#/components/responses/ValidationError"
  /sites/{id}/token:
    post:
      x-stability: stable
      x-required-scope: sites:write
      summary: Generate a registration token for this site
      description: |
        Creates a site-scoped agent registration token. The raw token is
        returned ONCE in this response — only its SHA-256 hash is stored, so
        it is unrecoverable afterwards. Default expiry is 24h; `expires_in`
        accepts a positive Go duration up to 8760h (12 months).
        `usage_limit`: 1 = single-use (default), 0 = unlimited until expiry,
        N = at most N registrations. Optional `policy_id` /
        `alert_template_id` are auto-assigned to agents enrolled with the
        token (both must belong to this org). List/revoke/extend live under
        `GET /sites/{id}/tokens` and `/registration-tokens/{id}`.
      operationId: generateSiteToken
      tags: [Sites]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                expires_in:
                  type: string
                  description: "Positive Go duration, e.g. 24h or 720h; max 8760h. Omitted = 24h"
                label:
                  type: string
                  maxLength: 120
                  nullable: true
                  description: Operator-set name to tell long-lived tokens apart
                policy_id: {type: string, format: uuid, nullable: true}
                alert_template_id: {type: string, format: uuid, nullable: true}
                usage_limit:
                  type: integer
                  minimum: 0
                  default: 1
                  description: "1 = single-use, 0 = unlimited, N = max N registrations"
      responses:
        "201":
          description: Registration token created; `token` is shown only in this response
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: {type: string, format: uuid}
                      site_id: {type: string, format: uuid}
                      token:
                        type: string
                        description: Raw registration token — store it now, it is never returned again
                      label: {type: string, nullable: true}
                      policy_id: {type: string, format: uuid, nullable: true}
                      alert_template_id: {type: string, format: uuid, nullable: true}
                      usage_limit: {type: integer}
                      usage_count: {type: integer}
                      expires_at: {type: string, format: date-time}
                      created_at: {type: string, format: date-time}
        "400":
          description: "Invalid expires_in/label, or policy_id/alert_template_id not in this org (VALIDATION_ERROR / INVALID_ASSIGNMENT)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /sites/{id}/tokens:
    get:
      x-stability: beta
      x-required-scope: sites:write
      summary: List registration tokens for a site
      description: |
        Returns metadata for all **unexpired** registration tokens of the site,
        newest first. Expired tokens are excluded. The raw token value is never
        returned — only its SHA-256 hash is stored, so possession of the raw
        token ends at generation time (`POST /sites/{id}/token`).

        `usage_limit` semantics: `1` = single-use (default), `0` = unlimited
        uses until expiry, any other positive value = that many registrations.
        `usage_count` tracks registrations consumed; `last_used_at` shows
        whether a token is actually in use before you revoke it.
      operationId: listSiteRegistrationTokens
      tags: [Agent Enrolment]
      parameters:
        - name: id
          in: path
          required: true
          description: Site ID
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Registration token metadata, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          format: uuid
                        site_id:
                          type: string
                          format: uuid
                        label:
                          type: string
                          maxLength: 120
                          nullable: true
                          description: Operator-set name to tell long-lived tokens apart
                        policy_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Policy auto-assigned to agents registered with this token
                        alert_template_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Alert template auto-assigned to agents registered with this token
                        usage_limit:
                          type: integer
                          description: "Maximum registrations: 1 = single-use (default), 0 = unlimited until expiry"
                          example: 1
                        usage_count:
                          type: integer
                          description: Registrations consumed so far
                          example: 0
                        last_used_at:
                          type: string
                          format: date-time
                          nullable: true
                          description: When the token last successfully registered an agent
                        expires_at:
                          type: string
                          format: date-time
                        created_at:
                          type: string
                          format: date-time
        "400":
          description: Invalid site ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
  /sites/{siteID}/managed-updates:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get managed-updates state for a site
      operationId: getSiteManagedUpdates
      tags: [Patches]
      parameters:
        - name: siteID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Direct + effective managed-updates view
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *ManagedUpdatesScopeView
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Set the site-level managed-updates override
      description: |
        Sets `sites.managed_updates` (nullable - `"managed": null` clears
        the override) and fans out set-update-control to every agent in
        the site.
      operationId: updateSiteManagedUpdates
      tags: [Patches]
      parameters:
        - name: siteID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema: *ManagedUpdatesUpdateRequest
      responses: *ManagedUpdatesFanOutResponses
  /sites/{siteID}/patch-policy:
    get:
      x-stability: internal
      x-required-scope: patches:read
      summary: Get the patch policy assigned to a site
      operationId: getSitePatchPolicy
      tags: [Patches]
      parameters:
        - name: siteID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Direct + effective patch-policy assignment
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *PatchPolicyAssignmentView
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: patches:write
      summary: Assign or clear the patch policy for a site
      operationId: assignSitePatchPolicy
      tags: [Patches]
      parameters:
        - name: siteID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema: *PatchPolicyAssignmentRequest
      responses:
        "204":
          description: Assignment updated
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "404": {$ref: '#/components/responses/NotFound'}
  /sites/{siteID}/policy:
    put:
      x-stability: stable
      x-required-scope: policies:write
      summary: Assign policies to a site
      description: |
        Sets the site-level workstation and server policies in one call
        (both columns are always written — omit/null a field to clear that
        assignment), then re-materialises policies for all agents under
        the site asynchronously.
      operationId: assignSitePolicy
      tags: [Policies]
      parameters:
        - name: siteID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema: *HierarchyPolicyAssignment
      responses:
        "200":
          description: Site policy updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /software:
    get:
      x-stability: internal
      x-required-scope: software:read
      summary: List software across all agents (organization-wide)
      description: |
        Distinct (name, version, publisher) rows with the count of
        agents reporting each. NOT paginated — hard-capped at 500 rows,
        sorted by name; use `search` to narrow.
      operationId: listSoftwareOrgWide
      tags: [Software]
      parameters:
        - name: search
          in: query
          description: Case-insensitive substring match on software name
          schema: {type: string}
      responses:
        "200":
          description: Organization-wide software summary (max 500 rows)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        name: {type: string}
                        version: {type: string}
                        publisher: {type: string}
                        agent_count: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /sso/exchange:
    post:
      x-stability: internal
      summary: Exchange an SSO authorization code for a bearer token
      description: |
        Final leg of the OIDC login flow: the callback redirects the SPA to
        /sso-callback?code=… with a short-lived (60s) single-use code; this
        endpoint swaps it for the real bearer token, keeping tokens out of
        URLs, browser history, and access logs. Rate-limited like /login.
      operationId: ssoExchange
      security: []
      tags: [SSO]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: {type: string, description: Single-use authorization code from the SSO callback redirect}
      responses:
        "200":
          description: Bearer token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      token: {type: string}
                      user: *AuthUser
        "400":
          description: Missing code (VALIDATION_ERROR)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Invalid or expired authorization code (INVALID_CODE)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
  /sso/providers:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: List SSO providers (admin)
      description: Full provider records for the tenant (client secrets never returned). Session-token only — API keys refused.
      operationId: listSSOProviders
      tags: [SSO]
      responses:
        "200":
          description: SSO provider list, ordered by name
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &SSOProvider
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        name: {type: string, maxLength: 255}
                        provider_type: {type: string, description: Always oidc}
                        client_id: {type: string}
                        server_url:
                          type: string
                          description: OIDC issuer base URL (discovery is validated on create/update)
                        role_id:
                          type: string
                          format: uuid
                          nullable: true
                          description: Role auto-assigned to users provisioned via this provider
                        enabled: {type: boolean}
                        allowed_domains:
                          type: string
                          description: Comma-separated email domains permitted for auto-provisioning; empty = any
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Create an SSO provider
      description: |
        Registers an OIDC provider. The server validates the issuer via OIDC
        discovery before saving (400 OIDC_DISCOVERY_FAILED on failure); the
        client secret is encrypted at rest. Session-token only.
      operationId: createSSOProvider
      tags: [SSO]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, server_url, client_id, client_secret]
              properties:
                name: {type: string, maxLength: 255}
                server_url: {type: string, description: OIDC issuer base URL}
                client_id: {type: string}
                client_secret: {type: string}
                role_id: {type: string, format: uuid, nullable: true}
                enabled: {type: boolean, default: true}
                allowed_domains:
                  type: string
                  description: Comma-separated email domains allowed for auto-provisioning
      responses:
        "201":
          description: Provider created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SSOProvider
        "400":
          description: Validation error or OIDC discovery failed (OIDC_DISCOVERY_FAILED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /sso/providers/public:
    get:
      x-stability: internal
      summary: List enabled SSO providers for the login page
      description: |
        Returns id+name for the enabled providers of ONE tenant, identified by
        the `org` slug. Always 200 with an empty array when the slug is
        missing, unknown, or has no providers — the endpoint cannot be used as
        an org-existence oracle.
      operationId: listPublicSSOProviders
      security: []
      tags: [SSO]
      parameters:
        - name: org
          in: query
          required: false
          description: Organization slug; omitting it yields an empty list
          schema: {type: string}
      responses:
        "200":
          description: Enabled providers for the org (possibly empty)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        name: {type: string}
  /sso/providers/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update an SSO provider
      description: |
        Partial update. A new server_url is re-validated via OIDC discovery; a
        new client_secret is re-encrypted. Session-token only.
      operationId: updateSSOProvider
      tags: [SSO]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
                server_url: {type: string}
                client_id: {type: string}
                client_secret: {type: string}
                role_id: {type: string, format: uuid}
                enabled: {type: boolean}
                allowed_domains: {type: string}
      responses:
        "200":
          description: Updated provider
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *SSOProvider
        "400":
          description: Validation error or OIDC discovery failed (OIDC_DISCOVERY_FAILED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      x-stability: internal
      x-required-scope: settings:write
      summary: Delete an SSO provider
      description: Removes the provider; linked sso_accounts rows cascade. Session-token only.
      operationId: deleteSSOProvider
      tags: [SSO]
      responses:
        "204":
          description: Provider deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /sso/{providerID}/authorize:
    get:
      x-stability: internal
      summary: Initiate SSO authorization (redirect to identity provider)
      description: |
        Starts the OIDC authorization-code flow with PKCE (S256) and a
        server-side state, then 302-redirects the browser to the identity
        provider.
      operationId: ssoAuthorize
      security: []
      tags: [SSO]
      parameters:
        - name: providerID
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "302":
          description: Redirect to the identity provider's authorization endpoint
        "400":
          description: Invalid provider ID
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Provider not found or disabled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /sso/{providerID}/callback:
    get:
      x-stability: internal
      summary: SSO callback from the identity provider
      description: |
        Validates state, exchanges the IdP code (PKCE), verifies the ID token,
        and logs in (or auto-provisions) the user. On success it 302-redirects
        the browser to the SPA at `/sso-callback?code=…` with a 60-second
        single-use code for POST /sso/exchange — the bearer token is never
        placed in the URL. Auto-provisioning respects the provider's
        allowed_domains list (403 SSO_DOMAIN_NOT_ALLOWED).
      operationId: ssoCallback
      security: []
      tags: [SSO]
      parameters:
        - name: providerID
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: state
          in: query
          required: true
          schema: {type: string}
        - name: code
          in: query
          required: true
          description: Authorization code issued by the identity provider
          schema: {type: string}
      responses:
        "302":
          description: Redirect to the SPA with a single-use exchange code
        "400":
          description: "Invalid/expired state (INVALID_STATE), provider mismatch (INVALID_PROVIDER), IdP error, or token exchange/verification failure (OIDC_ERROR)"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Account is disabled (AUTH_DISABLED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: Email domain not permitted for auto-provisioning (SSO_DOMAIN_NOT_ALLOWED)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /tasks:
    get:
      x-stability: stable
      x-required-scope: tasks:read
      summary: List tasks across all agents
      description: |
        Tenant-wide paginated task list ordered by name, with the target
        agent's hostname or the target smart group's name joined in.
      operationId: listTasks
      tags: [Tasks]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        "200":
          description: Paginated task list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          allOf:
                            - *Task
                            - type: object
                              properties:
                                agent_hostname: {type: string, nullable: true}
                                target_group_name: {type: string, nullable: true}
                      pagination: *TaskPaginationBlock
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
    post:
      x-stability: stable
      x-required-scope: tasks:write
      summary: Create a task (agent or smart-group target)
      description: |
        Body-driven create. Exactly one of agent_id or target_group_id is
        required (400 otherwise). Group-targeted tasks fan out to every
        current group member at run time; event-driven task types are not
        allowed on group targets.
      operationId: createTask
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema: *CreateTaskBody
      responses:
        "201":
          description: Task created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Task
        "400":
          description: Validation failure or missing/ambiguous target
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404":
          description: Agent or smart group not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "409":
          description: Smart group too large to target (GROUP_OVERSIZE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
  /tasks/summary:
    get:
      x-stability: stable
      x-required-scope: tasks:read
      summary: Get task status summary across all agents
      operationId: taskSummary
      tags: [Tasks]
      responses:
        "200":
          description: Aggregate task counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total: {type: integer}
                      enabled: {type: integer}
                      disabled: {type: integer}
                      passing: {type: integer}
                      failing: {type: integer}
                      unknown:
                        type: integer
                        description: Tasks that have never run (null last_status)
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /tasks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: tasks:read
      summary: Get task details
      description: Returns the task plus its 10 most recent results.
      operationId: getTask
      tags: [Tasks]
      responses:
        "200":
          description: Task with recent results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - *Task
                      - type: object
                        properties:
                          recent_results:
                            type: array
                            maxItems: 10
                            items: &TaskResult
                              type: object
                              description: One row from the task_results table
                              properties:
                                id: {type: string, format: uuid}
                                org_id: {type: string, format: uuid}
                                task_id: {type: string, format: uuid}
                                agent_id: {type: string, format: uuid}
                                status:
                                  type: string
                                  enum: [pass, fail]
                                results:
                                  type: array
                                  description: Per-action results
                                  items:
                                    type: object
                                    properties:
                                      action_index: {type: integer}
                                      retcode: {type: integer}
                                      stdout: {type: string}
                                      stderr: {type: string}
                                      execution_time: {type: number}
                                started_at: {type: string, format: date-time, nullable: true}
                                completed_at: {type: string, format: date-time, nullable: true}
                                created_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    put:
      x-stability: stable
      x-required-scope: tasks:write
      summary: Update a task
      description: |
        Partial update — only fields present are changed; next_run is
        recalculated. Target (agent_id/target_group_id) cannot be changed.
        Switching a group-targeted task to an event-driven task type is
        rejected with 400.
      operationId: updateTask
      tags: [Tasks]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string, maxLength: 255}
                task_type:
                  type: string
                  enum: [manual, once, daily, weekly, monthly, on_boot, on_onboarding, on_check_fail]
                actions:
                  type: array
                  minItems: 1
                  items: {type: object}
                run_time: {type: string, description: "HH:MM"}
                run_date: {type: string, format: date-time}
                weekly_days:
                  type: array
                  items: {type: integer, minimum: 0, maximum: 6}
                monthly_day: {type: integer, minimum: 1, maximum: 31}
                trigger_check_id: {type: string, format: uuid}
                enabled: {type: boolean}
                continue_on_error: {type: boolean}
                collect_all_output: {type: boolean}
                skip_if_offline: {type: boolean}
                alert_severity:
                  type: string
                  enum: [info, warning, error]
      responses:
        "200":
          description: Task updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Task
        "400":
          description: Validation failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
    delete:
      x-stability: stable
      x-required-scope: tasks:write
      summary: Delete a task
      operationId: deleteTask
      tags: [Tasks]
      responses:
        "204":
          description: Task deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /tasks/{id}/results:
    get:
      x-stability: stable
      x-required-scope: tasks:read
      summary: Get task execution history
      description: |
        Paginated result history, newest first, with the reporting agent's
        hostname and NATS id joined in (group-task history reports many
        agents under one task).
      operationId: taskResults
      tags: [Tasks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
        - name: status
          in: query
          schema:
            type: string
            enum: [pass, fail]
      responses:
        "200":
          description: Paginated task results
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          allOf:
                            - *TaskResult
                            - type: object
                              properties:
                                agent_hostname: {type: string, nullable: true}
                                agent_natsid: {type: string, nullable: true}
                      pagination: *TaskPaginationBlock
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "429": {$ref: '#/components/responses/RateLimited'}
  /tasks/{id}/run:
    post:
      x-stability: stable
      x-required-scope: tasks:write
      summary: Run a task immediately
      description: |
        Dispatches a manual run and returns 202 in every accepted case —
        the run itself is asynchronous (results land in task_results).
        Single-agent tasks: `dispatched` (online), `queued_offline`
        (queued for reconnect) or `skipped_offline` (skip_if_offline set).
        Group tasks fan out to current members: `dispatched_group`,
        `dispatched_group_partial` (some members unreachable),
        `no_members` or `no_online_members`; group responses carry
        members/dispatched/queued/unreached/skipped_offline counters.
      operationId: runTask
      tags: [Tasks]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "202":
          description: Run accepted (dispatched, queued or skipped)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      task_id: {type: string, format: uuid}
                      status:
                        type: string
                        enum: [dispatched, queued_offline, skipped_offline, dispatched_group, dispatched_group_partial, no_members, no_online_members]
                      message: {type: string}
                      online:
                        type: boolean
                        description: Single-agent runs only
                      members:
                        type: integer
                        description: Group runs only
                      dispatched: {type: integer}
                      queued: {type: integer}
                      unreached: {type: integer}
                      skipped_offline: {type: integer}
                      cleanup_failed: {type: boolean}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Smart group too large to run against (GROUP_OVERSIZE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "429": {$ref: '#/components/responses/RateLimited'}
        "502":
          description: NATS publish failed (NATS_PUBLISH_FAILED)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: NATS not available (NATS_UNAVAILABLE)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /threatdown/detections/ack-bulk:
    post:
      x-stability: internal
      x-required-scope: agents:write
      summary: Acknowledge multiple ThreatDown detections
      operationId: bulkAckThreatdownDetections
      tags: [ThreatDown]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [detection_ids]
              properties:
                detection_ids:
                  type: array
                  items: {type: string, format: uuid}
      responses:
        "200":
          description: Acknowledgement count
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      acknowledged_count: {type: integer}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /threatdown/detections/unacked:
    get:
      x-stability: internal
      x-required-scope: agents:read
      summary: List unacknowledged ThreatDown detections (org-wide)
      description: Includes agent, client and site context for the inbox view.
      operationId: listUnackedThreatdownDetections
      tags: [ThreatDown]
      responses:
        "200":
          description: Unacked detections
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        agent_id: {type: string, format: uuid, nullable: true}
                        threat_name: {type: string, nullable: true}
                        category: {type: string, nullable: true}
                        status: {type: string, nullable: true}
                        action_taken: {type: string, nullable: true}
                        path: {type: string, nullable: true}
                        process_name: {type: string, nullable: true}
                        machine_name: {type: string, nullable: true}
                        reported_at: {type: string, format: date-time, nullable: true}
                        created_at: {type: string, format: date-time}
                        agent_hostname: {type: string, nullable: true}
                        agent_display_name: {type: string, nullable: true}
                        client_id: {type: string, format: uuid, nullable: true}
                        client_name: {type: string, nullable: true}
                        site_id: {type: string, format: uuid, nullable: true}
                        site_name: {type: string, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /threatdown/endpoints/{id}/link:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Link or unlink a ThreatDown endpoint to an RMM agent
      description: agent_id null unlinks the endpoint.
      operationId: linkThreatdownEndpoint
      tags: [ThreatDown]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                agent_id:
                  type: string
                  format: uuid
                  nullable: true
      responses:
        "200":
          description: Link updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /threatdown/rematch-unmatched:
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Re-run agent matching for unmatched ThreatDown endpoints
      description: No request body.
      operationId: rematchThreatdownUnmatched
      tags: [ThreatDown]
      responses:
        "200":
          description: Relink count
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      relinked: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /threatdown/scan/{agentID}:
    parameters:
      - name: agentID
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: agents:write
      summary: Trigger a ThreatDown threat scan on an agent
      description: |
        Dispatches a ThreatScan via OneView for the agent's linked
        endpoint; poll /agents/{id}/threatdown/jobs/{jobID} for status.
        No request body.
      operationId: scanThreatdownAgent
      tags: [ThreatDown]
      responses:
        "200":
          description: Scan triggered
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      message: {type: string}
                      job_id: {type: string}
                      jobs:
                        type: array
                        items: {type: object}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /threatdown/sites:
    get:
      x-stability: internal
      x-required-scope: clients:write
      summary: List OneView sites available to the org
      operationId: listThreatdownSites
      tags: [ThreatDown]
      responses:
        "200":
          description: Sites proxied from OneView
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      sites:
                        type: array
                        items: {type: object}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /threatdown/subscriptions/availability:
    get:
      x-stability: internal
      x-required-scope: clients:write
      summary: Get OneView subscription availability
      description: Proxied OneView availability document (free-form).
      operationId: getThreatdownSubscriptionsAvailability
      tags: [ThreatDown]
      responses:
        "200":
          description: Availability document
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /threatdown/subscriptions/report:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: Get the OneView subscriptions usage report
      description: Proxied OneView report document (free-form).
      operationId: getThreatdownSubscriptionsReport
      tags: [ThreatDown]
      responses:
        "200":
          description: Subscriptions report
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /threatdown/sync:
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Run a full ThreatDown sync now
      description: Pulls sites, endpoints and detections from OneView. No request body.
      operationId: syncThreatdown
      tags: [ThreatDown]
      responses:
        "200":
          description: Sync result
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      clients_synced: {type: integer}
                      endpoints_upserted: {type: integer}
                      detections_upserted: {type: integer}
                      agents_matched: {type: integer}
                      orphans_pruned: {type: integer}
                      errors:
                        type: array
                        items: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "502":
          description: OneView API error
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
  /threatdown/unmatched:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: List ThreatDown endpoints with no linked agent
      operationId: listThreatdownUnmatched
      tags: [ThreatDown]
      responses:
        "200":
          description: Unmatched endpoints
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        td_machine_id: {type: string}
                        host_name: {type: string, nullable: true}
                        protection_status: {type: string, nullable: true}
                        os_type: {type: string, nullable: true}
                        os_platform: {type: string, nullable: true}
                        last_seen: {type: string, format: date-time, nullable: true}
                        last_scanned: {type: string, format: date-time, nullable: true}
                        client_id: {type: string, format: uuid, nullable: true}
                        client_name: {type: string, nullable: true}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /tray-configs:
    get:
      x-stability: internal
      x-required-scope: tray:read
      summary: List tray configs
      description: |
        Returns every tray config row for the org (all scopes), ordered
        by scope type then creation date. Not paginated.
      operationId: listTrayConfigs
      tags: [Tray]
      responses:
        "200":
          description: Tray config list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &TrayConfig
                          type: object
                          description: One tray config row scoped to an org/client/site/agent
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            scope_type:
                              type: string
                              enum: [org, client, site, agent]
                            scope_id:
                              type: string
                              format: uuid
                              description: ID of the org/client/site/agent the config applies to
                            company_name:
                              type: string
                              nullable: true
                            support_phone:
                              type: string
                              nullable: true
                            support_email:
                              type: string
                              nullable: true
                            support_url:
                              type: string
                              nullable: true
                            icon_url:
                              type: string
                              nullable: true
                            logo_url:
                              type: string
                              nullable: true
                            primary_color:
                              type: string
                              nullable: true
                            selfservice_scripts:
                              type: array
                              items: {type: object}
                              description: Legacy JSONB self-service catalogue (superseded by tray policy scripts)
                            enabled: {type: boolean}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      x-required-scope: tray:write
      summary: Create a tray config
      description: |
        Creates a config at one scope. `enabled` defaults to true and
        `selfservice_scripts` to `[]`. The resolved config is pushed to
        all affected agents via NATS immediately after creation.
      operationId: createTrayConfig
      tags: [Tray]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope_type, scope_id]
              properties:
                scope_type:
                  type: string
                  enum: [org, client, site, agent]
                scope_id: {type: string, format: uuid}
                company_name:
                  type: string
                  nullable: true
                support_phone:
                  type: string
                  nullable: true
                support_email:
                  type: string
                  nullable: true
                support_url:
                  type: string
                  nullable: true
                icon_url:
                  type: string
                  nullable: true
                logo_url:
                  type: string
                  nullable: true
                primary_color:
                  type: string
                  nullable: true
                selfservice_scripts:
                  type: array
                  items: {type: object}
                enabled:
                  type: boolean
                  default: true
      responses:
        "201":
          description: Config created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TrayConfig
        "400":
          description: Invalid body, scope_type, or missing scope_id
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /tray-configs/resolve/{agentID}:
    get:
      x-stability: internal
      x-required-scope: tray:read
      summary: Resolve the effective tray config for an agent
      description: |
        Walks the agent > site > client > org hierarchy and merges
        configs field-by-field (first non-null wins; `enabled` from the
        most specific scope wins outright and, when false, stops the
        branding merge). The result is a synthetic merged row — `id`,
        `org_id`, `scope_type`, `scope_id`, and timestamps are
        zero-values, not a real row's.
      operationId: resolveTrayConfig
      tags: [Tray]
      parameters:
        - name: agentID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Effective config (synthetic merged row)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TrayConfig
        "400":
          description: Invalid agent ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /tray-configs/{configID}/scripts:
    get:
      x-stability: internal
      x-required-scope: tray:read
      summary: List self-service scripts in a tray config
      description: |
        Returns the self-service script catalogue bound to one tray
        config, ordered by sort_order then display name. Each entry
        joins the underlying script's name and shell type. Not
        paginated.
      operationId: listTrayConfigScripts
      tags: [Tray]
      parameters:
        - name: configID
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Catalogue entries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &TrayPolicyScript
                          type: object
                          description: One self-service catalogue entry binding a script to a tray config
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            tray_config_id: {type: string, format: uuid}
                            script_id: {type: string, format: uuid}
                            script_name:
                              type: string
                              description: Joined from the scripts table
                            shell_type:
                              type: string
                              description: Joined from the scripts table
                            display_name: {type: string}
                            description:
                              type: string
                              nullable: true
                            icon:
                              type: string
                              nullable: true
                            confirmation_prompt:
                              type: string
                              nullable: true
                            requires_approval:
                              type: boolean
                              description: When true, end-user runs queue as pending until approved
                            rate_limit_per_hour:
                              type: integer
                              description: 0 = unlimited
                            timeout_seconds: {type: integer}
                            sort_order: {type: integer}
                            enabled: {type: boolean}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "400":
          description: Invalid config ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      x-required-scope: tray:write
      summary: Add a script to a tray config catalogue
      description: |
        Binds an existing script to the tray config with end-user-facing
        metadata. Defaults: requires_approval false, rate_limit_per_hour
        0 (unlimited), timeout_seconds 120 (max 3600), sort_order 0,
        enabled true. Affected agents are pushed the updated catalogue
        via NATS.
      operationId: addTrayConfigScript
      tags: [Tray]
      parameters:
        - name: configID
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [script_id, display_name]
              properties:
                script_id: {type: string, format: uuid}
                display_name: {type: string}
                description:
                  type: string
                  nullable: true
                icon:
                  type: string
                  nullable: true
                confirmation_prompt:
                  type: string
                  nullable: true
                requires_approval:
                  type: boolean
                  default: false
                rate_limit_per_hour:
                  type: integer
                  minimum: 0
                  default: 0
                timeout_seconds:
                  type: integer
                  minimum: 1
                  maximum: 3600
                  default: 120
                sort_order:
                  type: integer
                  default: 0
                enabled:
                  type: boolean
                  default: true
      responses:
        "201":
          description: Catalogue entry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TrayPolicyScript
        "400":
          description: Invalid body or script not found in this tenant
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /tray-configs/{id}:
    get:
      x-stability: internal
      x-required-scope: tray:read
      summary: Get a tray config
      operationId: getTrayConfig
      tags: [Tray]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Tray config
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TrayConfig
        "400":
          description: Invalid ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: tray:write
      summary: Update a tray config
      description: |
        Partial update — only non-null fields in the body are changed
        (COALESCE semantics). `scope_type`/`scope_id` cannot be changed.
        The resolved config is pushed to all affected agents via NATS.
      operationId: updateTrayConfig
      tags: [Tray]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                company_name:
                  type: string
                  nullable: true
                support_phone:
                  type: string
                  nullable: true
                support_email:
                  type: string
                  nullable: true
                support_url:
                  type: string
                  nullable: true
                icon_url:
                  type: string
                  nullable: true
                logo_url:
                  type: string
                  nullable: true
                primary_color:
                  type: string
                  nullable: true
                selfservice_scripts:
                  type: array
                  items: {type: object}
                enabled: {type: boolean}
      responses:
        "200":
          description: Updated config
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TrayConfig
        "400":
          description: Invalid ID or body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    delete:
      x-stability: internal
      x-required-scope: tray:write
      summary: Delete a tray config
      operationId: deleteTrayConfig
      tags: [Tray]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [deleted]
        "400":
          description: Invalid ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /tray-notifications:
    get:
      x-stability: internal
      x-required-scope: tray:write
      summary: List tray notification history
      description: |
        Paginated history of sent tray notifications, newest first. Uses
        `page`/`per_page` with a `pagination` meta object. Note the
        scope: the whole notification surface (including this read) is
        gated by the notify permission, which maps to `tray:write`.
      operationId: listTrayNotifications
      tags: [Tray]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        "200":
          description: Notification history
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &TrayNotification
                          type: object
                          description: One persisted tray push notification
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            target_type:
                              type: string
                              enum: [agent, site, client, org]
                            target_id: {type: string, format: uuid}
                            title: {type: string}
                            body: {type: string}
                            urgency: {type: string}
                            created_by:
                              type: string
                              format: uuid
                              nullable: true
                            created_at: {type: string, format: date-time}
                            expires_at:
                              type: string
                              format: date-time
                              nullable: true
                      pagination:
                        $ref: '#/components/schemas/PaginationMeta'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      x-required-scope: tray:write
      summary: Send a tray notification
      description: |
        Persists the notification and delivers it via NATS to every
        agent under the target (agent, site, client, or whole org).
        `urgency` defaults to `normal`. When resolving target agents
        fails the notification is still saved and the response carries
        `delivered: 0` without `total_agents`.
      operationId: createTrayNotification
      tags: [Tray]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_type, target_id, title, body]
              properties:
                target_type:
                  type: string
                  enum: [agent, site, client, org]
                target_id: {type: string, format: uuid}
                title: {type: string}
                body: {type: string}
                urgency:
                  type: string
                  default: normal
                expires_at:
                  type: string
                  format: date-time
                  nullable: true
      responses:
        "201":
          description: Notification created (and delivery attempted)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      notification: *TrayNotification
                      delivered:
                        type: integer
                        description: Agents the NATS publish succeeded for
                      total_agents:
                        type: integer
                        description: Agents resolved under the target (absent when resolution failed)
        "400":
          description: Invalid body, target_type, or missing fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /tray-notifications/{id}:
    delete:
      x-stability: internal
      x-required-scope: tray:write
      summary: Delete a tray notification record
      operationId: deleteTrayNotification
      tags: [Tray]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [deleted]
        "400":
          description: Invalid ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /tray-policy-scripts/{id}:
    put:
      x-stability: internal
      x-required-scope: tray:write
      summary: Update a self-service catalogue entry
      description: |
        Partial update — only non-null fields change (COALESCE
        semantics; an empty `display_name` is ignored rather than
        applied). `tray_config_id`/`script_id` cannot be changed.
        Affected agents are pushed the updated catalogue via NATS.
      operationId: updateTrayPolicyScript
      tags: [Tray]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: {type: string}
                description:
                  type: string
                  nullable: true
                icon:
                  type: string
                  nullable: true
                confirmation_prompt:
                  type: string
                  nullable: true
                requires_approval: {type: boolean}
                rate_limit_per_hour:
                  type: integer
                  minimum: 0
                timeout_seconds:
                  type: integer
                  minimum: 1
                  maximum: 3600
                sort_order: {type: integer}
                enabled: {type: boolean}
      responses:
        "200":
          description: Updated entry
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *TrayPolicyScript
        "400":
          description: Invalid ID, body, or out-of-range timeout/rate limit
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    delete:
      x-stability: internal
      x-required-scope: tray:write
      summary: Remove a self-service catalogue entry
      operationId: deleteTrayPolicyScript
      tags: [Tray]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [deleted]
        "400":
          description: Invalid ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /tray-script-runs:
    get:
      x-stability: internal
      x-required-scope: tray:read
      summary: List tray self-service script runs
      description: |
        Run audit + approval inbox for end-user-initiated script runs,
        newest first. Filter on `status` and/or `agent_id`. Capped by
        `limit` (default 100, max 500) — no offset paging.
      operationId: listTrayScriptRuns
      tags: [Tray]
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, approved, denied, dispatched, running, succeeded, failed, timeout, cancelled]
        - name: agent_id
          in: query
          schema: {type: string, format: uuid}
        - name: limit
          in: query
          schema:
            type: integer
            default: 100
            maximum: 500
      responses:
        "200":
          description: Run list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          description: One self-service run audit row
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            tray_policy_script_id:
                              type: string
                              format: uuid
                              nullable: true
                            script_id:
                              type: string
                              format: uuid
                              nullable: true
                            script_name:
                              type: string
                              nullable: true
                            display_name:
                              type: string
                              nullable: true
                            agent_id: {type: string, format: uuid}
                            agent_hostname:
                              type: string
                              nullable: true
                            requested_local_user:
                              type: string
                              nullable: true
                            status:
                              type: string
                              enum: [pending, approved, denied, dispatched, running, succeeded, failed, timeout, cancelled]
                            approved_by_user_id:
                              type: string
                              format: uuid
                              nullable: true
                            denied_by_user_id:
                              type: string
                              format: uuid
                              nullable: true
                            approved_at:
                              type: string
                              format: date-time
                              nullable: true
                            denied_at:
                              type: string
                              format: date-time
                              nullable: true
                            dispatched_at:
                              type: string
                              format: date-time
                              nullable: true
                            completed_at:
                              type: string
                              format: date-time
                              nullable: true
                            retcode:
                              type: integer
                              nullable: true
                            execution_time_seconds:
                              type: number
                              nullable: true
                            stdout_snippet:
                              type: string
                              nullable: true
                            stderr_snippet:
                              type: string
                              nullable: true
                            error_message:
                              type: string
                              nullable: true
                            correlation_id: {type: string, format: uuid}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /tray-script-runs/{id}/approve:
    post:
      x-stability: internal
      x-required-scope: tray:write
      summary: Approve a pending self-service run
      description: |
        Moves a pending run to `dispatched`, records the approver, and
        pushes the script body to the agent over NATS for execution as
        SYSTEM. 409 INVALID_STATE when the run is not pending. No
        request body.
      operationId: approveTrayScriptRun
      tags: [Tray]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Run dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [dispatched]
        "400":
          description: Invalid run ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "409":
          description: Run is not pending
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /tray-script-runs/{id}/deny:
    post:
      x-stability: internal
      x-required-scope: tray:write
      summary: Deny a pending self-service run
      description: |
        Moves a pending run to `denied` and records the denier and
        optional reason (stored in `error_message`). 409 INVALID_STATE
        when the run is not pending (also returned for unknown IDs —
        the update matches zero rows either way).
      operationId: denyTrayScriptRun
      tags: [Tray]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: {type: string}
      responses:
        "200":
          description: Run denied
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status:
                        type: string
                        enum: [denied]
        "400":
          description: Invalid run ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "409":
          description: Run is not pending (or does not exist)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /triggers:
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: List automation triggers
      description: All trigger management uses the settings-manage permission.
      operationId: listTriggers
      tags: [Triggers]
      responses:
        "200":
          description: Trigger list (bare array)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: &Trigger
                      type: object
                      properties:
                        id: {type: string, format: uuid}
                        org_id: {type: string, format: uuid}
                        name: {type: string}
                        description: {type: string, nullable: true}
                        trigger_type: {type: string}
                        enabled: {type: boolean}
                        agent_id: {type: string, format: uuid, nullable: true}
                        site_id: {type: string, format: uuid, nullable: true}
                        client_id: {type: string, format: uuid, nullable: true}
                        condition:
                          type: object
                          description: Condition JSON (shape depends on trigger_type)
                        actions:
                          type: array
                          description: Action list JSON
                          items: {type: object}
                        cooldown_seconds: {type: integer}
                        last_fired: {type: string, format: date-time, nullable: true}
                        fire_count: {type: integer}
                        created_by: {type: string, format: uuid, nullable: true}
                        created_at: {type: string, format: date-time}
                        updated_at: {type: string, format: date-time}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Create a trigger
      operationId: createTrigger
      tags: [Triggers]
      requestBody:
        required: true
        content:
          application/json:
            schema: &TriggerWriteBody
              type: object
              required: [name, trigger_type]
              properties:
                name: {type: string, maxLength: 255}
                description: {type: string, nullable: true}
                trigger_type: {type: string}
                enabled: {type: boolean, nullable: true}
                agent_id: {type: string, format: uuid, nullable: true}
                site_id: {type: string, format: uuid, nullable: true}
                client_id: {type: string, format: uuid, nullable: true}
                condition:
                  type: object
                  description: Condition JSON
                actions:
                  type: array
                  description: Action list JSON
                  items: {type: object}
                cooldown_seconds: {type: integer, nullable: true}
      responses:
        "201":
          description: Trigger created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Trigger
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /triggers/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: Get a trigger
      description: Includes history_count alongside the trigger fields.
      operationId: getTrigger
      tags: [Triggers]
      responses:
        "200":
          description: Trigger with history count
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - *Trigger
                      - type: object
                        properties:
                          history_count: {type: integer}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    put:
      x-stability: internal
      x-required-scope: settings:write
      summary: Update a trigger
      description: Partial update — only provided fields change.
      operationId: updateTrigger
      tags: [Triggers]
      requestBody:
        required: true
        content:
          application/json:
            schema: *TriggerWriteBody
      responses:
        "200":
          description: Updated trigger
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *Trigger
        "400": {$ref: '#/components/responses/ValidationError'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
    delete:
      x-stability: internal
      x-required-scope: settings:write
      summary: Delete a trigger
      operationId: deleteTrigger
      tags: [Triggers]
      responses:
        "204":
          description: Trigger deleted
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /triggers/{id}/history:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    get:
      x-stability: internal
      x-required-scope: settings:write
      summary: List a trigger's execution history
      description: Paginated (page/per_page), newest first.
      operationId: getTriggerHistory
      tags: [Triggers]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'
      responses:
        "200":
          description: Paginated history
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            trigger_id: {type: string, format: uuid}
                            agent_id: {type: string, format: uuid, nullable: true}
                            fired_at: {type: string, format: date-time}
                            condition_data:
                              type: object
                              description: Snapshot of the matched condition data
                            actions_result:
                              type: object
                              description: Per-action execution results
                            status: {type: string}
                      pagination: {$ref: '#/components/schemas/PaginationMeta'}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /triggers/{id}/test:
    parameters:
      - name: id
        in: path
        required: true
        schema: {type: string, format: uuid}
    post:
      x-stability: internal
      x-required-scope: settings:write
      summary: Test-fire a trigger
      description: Executes the trigger's actions with synthetic data. No request body.
      operationId: testTrigger
      tags: [Triggers]
      responses:
        "200":
          description: Fired
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      status: {type: string, example: fired}
                      message: {type: string}
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /users:
    get:
      x-stability: stable
      x-required-scope: users:read
      summary: List users in the current tenant
      description: |
        Active users only (soft-deleted users are excluded), newest first.
        `search` matches username or email (case-insensitive substring).
        Each row embeds a lightweight `role {id, name}` when a role is
        assigned.
      operationId: listUsers
      tags: [Users]
      parameters:
        - name: search
          in: query
          schema: {type: string}
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated user list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &UserRecord
                          type: object
                          description: User record (password hash never returned)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            username: {type: string, minLength: 3, maxLength: 150}
                            email: {type: string, format: email, maxLength: 255}
                            role_id: {type: string, format: uuid, nullable: true}
                            role:
                              type: object
                              nullable: true
                              description: Embedded role projection (list endpoint only)
                              properties:
                                id: {type: string, format: uuid}
                                name: {type: string}
                            is_superuser: {type: boolean}
                            is_active: {type: boolean}
                            last_login: {type: string, format: date-time, nullable: true}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                      pagination:
                        $ref: "#/components/schemas/PaginationMeta"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      x-stability: stable
      x-required-scope: users:write
      summary: Create a new user
      description: |
        Username must be globally unique (409 DUPLICATE_USERNAME). New users
        are always created with `is_superuser=false`; only an existing
        superuser can promote via PUT.
      operationId: createUser
      tags: [Users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, email, password]
              properties:
                username: {type: string, minLength: 3, maxLength: 150}
                email: {type: string, format: email, maxLength: 255}
                password: {type: string, minLength: 8}
                role_id: {type: string, format: uuid, nullable: true}
      responses:
        "201":
          description: User created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *UserRecord
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Username already exists (DUPLICATE_USERNAME)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /users/me:
    patch:
      x-stability: internal
      summary: Update the current user
      description: |
        Narrow self-service patch — only whitelisted fields are
        accepted. Today that is `locale` (en_gb or en_us); more fields
        land in later phases. A body with no recognised field is a no-op
        204. No permission gate: every authenticated user can edit their
        own record.
      operationId: patchCurrentUser
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                locale:
                  type: string
                  enum: [en_gb, en_us]
      responses:
        "204":
          description: Updated (or no-op when no patchable field was supplied)
        "400":
          description: Invalid body or unsupported locale (INVALID_LOCALE)
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
  /users/me/table-prefs/{key}:
    parameters:
      - name: key
        in: path
        required: true
        description: Table identifier (1-128 chars), e.g. agents-list
        schema: {type: string, maxLength: 128}
    get:
      x-stability: internal
      summary: Get table layout preferences
      description: |
        Returns the caller's stored layout preferences (column order,
        visibility, widths) for one table key. A missing row is not an
        error — it returns `{"prefs": {}}` so the client falls back to
        defaults. The prefs blob is opaque to the server; the frontend
        composable owns its schema.
      operationId: getTablePrefs
      tags: [Account]
      responses:
        "200":
          description: Stored prefs (empty object when none saved)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      prefs:
                        type: object
                        description: 'Opaque layout blob, e.g. {order, hidden, widths}'
        "400":
          description: Invalid table key
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
    put:
      x-stability: internal
      summary: Save table layout preferences
      description: |
        Upserts the caller's layout preferences for one table key.
        `prefs` must be a JSON object (arrays/strings rejected); the
        whole body is capped at 64 KiB. Rows are keyed on the
        authenticated user — no cross-user access is possible.
      operationId: putTablePrefs
      tags: [Account]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prefs]
              properties:
                prefs:
                  type: object
                  description: Opaque layout blob owned by the frontend composable
      responses:
        "204":
          description: Preferences saved
        "400":
          description: Invalid table key or prefs is not a JSON object
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ErrorResponse'}
        "401": {$ref: '#/components/responses/Unauthorized'}
  /users/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      x-stability: stable
      x-required-scope: users:read
      summary: Get a user by ID
      operationId: getUser
      tags: [Users]
      responses:
        "200":
          description: User details (no embedded role on this endpoint)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *UserRecord
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      x-stability: stable
      x-required-scope: users:write
      summary: Update a user
      description: |
        Partial update — only supplied fields are applied. Changing
        `is_superuser` additionally requires the CALLER to be a superuser
        (403 otherwise). `is_active` toggles the soft-delete flag.
      operationId: updateUser
      tags: [Users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                username: {type: string, minLength: 3, maxLength: 150}
                email: {type: string, format: email, maxLength: 255}
                role_id: {type: string, format: uuid}
                is_superuser: {type: boolean, description: Caller must be a superuser to change this}
                is_active: {type: boolean}
      responses:
        "200":
          description: Updated user
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: *UserRecord
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: Username already exists (DUPLICATE_USERNAME)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      x-stability: stable
      x-required-scope: users:write
      summary: Soft-delete a user
      description: |
        Sets `is_active=false` and revokes ALL of the user's sessions and API
        keys so existing credentials cannot be replayed if the account is
        later re-enabled. Already-inactive users return 404.
      operationId: deleteUser
      tags: [Users]
      responses:
        "204":
          description: User deactivated; sessions and API keys revoked
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /users/{id}/password:
    post:
      x-stability: stable
      x-required-scope: users:write
      summary: Change a user's password
      description: |
        Verifies `current_password` then sets `new_password`. The caller must
        be changing their OWN password or be a superuser (403 otherwise).
        Session-token only — API keys are refused (403 API_KEY_FORBIDDEN) so a
        leaked key cannot take over accounts. For forgotten passwords use the
        public POST /password/reset-request flow instead.
      operationId: changePassword
      tags: [Users]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: {type: string}
                new_password:
                  type: string
                  minLength: 8
                  description: Must differ from the current password
      responses:
        "204":
          description: Password changed
        "400":
          description: Validation error, or current password incorrect (INVALID_PASSWORD)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /virtualization/hosts:
    get:
      x-stability: internal
      x-required-scope: virtualization:read
      summary: List virtualization hosts
      description: |
        Hypervisor nodes detected by agents (Proxmox today; hyperv/esxi
        reserved). `limit`/`offset` paging with `{items, total}` —
        limit defaults to 200, max 500.
      operationId: listVirtualizationHosts
      tags: [Virtualization]
      parameters:
        - name: kind
          in: query
          schema:
            type: string
            enum: [proxmox, hyperv, esxi]
        - name: limit
          in: query
          schema:
            type: integer
            default: 200
            maximum: 500
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: Host list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &VirtHost
                          type: object
                          description: One detected hypervisor node (1:1 with the agent on it)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            agent_id:
                              type: string
                              format: uuid
                              description: Agent running on the hypervisor node
                            kind:
                              type: string
                              enum: [proxmox, hyperv, esxi]
                            version:
                              type: string
                              nullable: true
                            node_name:
                              type: string
                              nullable: true
                            cluster_name:
                              type: string
                              nullable: true
                            detected_at: {type: string, format: date-time}
                            last_synced_at:
                              type: string
                              format: date-time
                              nullable: true
                            last_sync_error:
                              type: string
                              nullable: true
                            properties:
                              type: object
                              description: Provider-specific extras (free-form)
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                            client_name:
                              type: string
                              nullable: true
                              description: Decorated via agent→site→client join (omitted when not populated)
                            site_name:
                              type: string
                              nullable: true
                              description: Decorated via agent→site join (omitted when not populated)
                      total: {type: integer}
        "400":
          description: Invalid kind
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
  /virtualization/hosts/{id}:
    get:
      x-stability: internal
      x-required-scope: virtualization:read
      summary: Get a virtualization host
      operationId: getVirtualizationHost
      tags: [Virtualization]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: Host detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      host: *VirtHost
        "400":
          description: Invalid host ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /virtualization/hosts/{id}/vms:
    get:
      x-stability: internal
      x-required-scope: virtualization:read
      summary: List VMs on a host
      description: |
        All VMs synced for one host, plus the host row inline so the
        detail page renders on a single fetch. `?include=agent_link`
        eagerly populates each VM's `managed_agent` ref (omitted when
        the VM is unmanaged or the include is not requested).
      operationId: listVirtualizationHostVms
      tags: [Virtualization]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
        - name: include
          in: query
          schema:
            type: string
            enum: [agent_link]
      responses:
        "200":
          description: VM list with host inline
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      items:
                        type: array
                        items: &VirtMachine
                          type: object
                          description: One synced virtual machine (inventory only — never billed)
                          properties:
                            id: {type: string, format: uuid}
                            org_id: {type: string, format: uuid}
                            host_id: {type: string, format: uuid}
                            external_id:
                              type: string
                              description: Provider-side VM ID (Proxmox vmid as text)
                            name: {type: string}
                            status:
                              type: string
                              enum: [running, stopped, paused, suspended, unknown]
                            kind:
                              type: string
                              description: "'qemu' or 'lxc' for Proxmox"
                            cpu_cores:
                              type: integer
                              nullable: true
                            memory_mb:
                              type: integer
                              format: int64
                              nullable: true
                            disk_gb:
                              type: integer
                              format: int64
                              nullable: true
                            smbios_uuid:
                              type: string
                              nullable: true
                            agent_id:
                              type: string
                              format: uuid
                              nullable: true
                              description: Managed OpsMerge agent matched to this VM (null when unmanaged)
                            properties:
                              type: object
                              description: Provider-specific extras (free-form)
                            last_seen_at: {type: string, format: date-time}
                            created_at: {type: string, format: date-time}
                            updated_at: {type: string, format: date-time}
                            managed_agent:
                              type: object
                              nullable: true
                              description: Populated only with ?include=agent_link on managed VMs
                              properties:
                                id: {type: string, format: uuid}
                                agent_id:
                                  type: string
                                  description: NATS agent ID string
                                hostname:
                                  type: string
                                  nullable: true
                                is_online: {type: boolean}
                      total: {type: integer}
                      host: *VirtHost
        "400":
          description: Invalid host ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /virtualization/vms/{id}:
    get:
      x-stability: internal
      x-required-scope: virtualization:read
      summary: Get a virtual machine
      description: Returns the VM plus its parent host in one round trip.
      operationId: getVirtualMachine
      tags: [Virtualization]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      responses:
        "200":
          description: VM detail with parent host
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      vm: *VirtMachine
                      host: *VirtHost
        "400":
          description: Invalid VM ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
  /virtualization/vms/{id}/action:
    post:
      x-stability: internal
      x-required-scope: virtualization:execute
      summary: Dispatch a VM lifecycle action
      description: |
        Dispatches start/stop/restart/reset to the host's agent over
        NATS. The action is audited BEFORE dispatch (500
        AUDIT_WRITE_FAILED if the audit write fails — nothing is
        dispatched without a trail). 202 means dispatched, not
        completed; the sync cycle reflects the outcome in subsequent VM
        list reads. `reset` is a hard power cycle with data-loss risk.
      operationId: dispatchVmAction
      tags: [Virtualization]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [start, stop, restart, reset]
      responses:
        "202":
          description: Action dispatched to the agent
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      dispatched: {type: boolean}
                      correlation_id:
                        type: string
                        format: uuid
                        description: Matches the audit log entry for this dispatch
        "400":
          description: Invalid VM ID, body, or action
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "502":
          description: Provider dispatch failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Virtualization provider not available (NATS down)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /virtualization/vms/{id}/stats:
    get:
      x-stability: internal
      x-required-scope: virtualization:read
      summary: Get live stats for a VM
      description: |
        Fetches a time-series stats window live from the host's agent
        (not persisted). `window` is clamped to 60-900 seconds, default
        300. Series arrays are ordered newest-last.
      operationId: getVmStats
      tags: [Virtualization]
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string, format: uuid}
        - name: window
          in: query
          description: Window in seconds (clamped to 60-900)
          schema:
            type: integer
            default: 300
            minimum: 60
            maximum: 900
      responses:
        "200":
          description: Stats window
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      stats:
                        type: object
                        properties:
                          external_id: {type: string}
                          collected_at: {type: string, format: date-time}
                          window_seconds: {type: integer}
                          cpu_percent:
                            type: array
                            items: {type: number}
                          memory_used_bytes:
                            type: array
                            items: {type: integer, format: int64}
                          memory_total_bytes:
                            type: integer
                            format: int64
                          disk_read_bps:
                            type: array
                            items: {type: integer, format: int64}
                            description: Omitted when the provider has no disk series
                          disk_write_bps:
                            type: array
                            items: {type: integer, format: int64}
                            description: Omitted when the provider has no disk series
                          net_rx_bps:
                            type: array
                            items: {type: integer, format: int64}
                            description: Omitted when the provider has no network series
                          net_tx_bps:
                            type: array
                            items: {type: integer, format: int64}
                            description: Omitted when the provider has no network series
        "400":
          description: Invalid VM ID
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "401": {$ref: '#/components/responses/Unauthorized'}
        "403": {$ref: '#/components/responses/Forbidden'}
        "404": {$ref: '#/components/responses/NotFound'}
        "502":
          description: Provider dispatch failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "503":
          description: Virtualization provider not available (NATS down)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "504":
          description: Agent did not respond
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /webhooks/threatdown/{secret}:
    post:
      x-stability: internal
      operationId: receiveThreatdownWebhook
      summary: ThreatDown OneView webhook
      description: |
        Receives push events from Malwarebytes / ThreatDown OneView
        (THREAT_FOUND, THREAT_CLEANED, THREAT_SCAN_SUCCESS,
        THREAT_REMEDIATE_SUCCESS/FAILURE). Authentication is the per-tenant
        UUID embedded in the path, matched against
        organizations.td_webhook_secret. When an HMAC key is on file
        (auto-subscribed flow) the `X-MWB-Signature` header is additionally
        REQUIRED and fail-closed: hex HMAC-SHA256 over the raw body (optional
        `sha256=` prefix and surrounding quotes are tolerated), constant-time
        compared. Manual setups without a stored key skip the header check.
        Body capped at 1 MB. Once authenticated, malformed payloads are
        acknowledged with 200 so OneView does not retry indefinitely.
      tags: ["Webhooks (Inbound)"]
      security: []
      parameters:
        - name: secret
          in: path
          required: true
          description: Per-tenant webhook secret (UUID) configured as part of the OneView subscription URL.
          schema:
            type: string
            format: uuid
        - name: X-MWB-Signature
          in: header
          required: false
          description: Hex HMAC-SHA256 of the raw body keyed with the per-tenant ThreatDown secret token. Required (fail-closed) when a key is stored for the tenant.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  type: string
                id:
                  type: string
                account_id:
                  type: string
                subscription_id:
                  type: string
                payload:
                  type: object
                  description: "OneView event detail: type, severity, machine_id/name, and `details.detections[]` for threat events."
      responses:
        "200":
          description: Event processed (or acknowledged as malformed/unhandled so OneView stops retrying). Body `{"ok":true}`.
        "400":
          description: Body unreadable or exceeded the 1 MB cap.
        "401":
          description: Unknown/invalid path secret, or missing/mismatched `X-MWB-Signature` when a key is on file.
        "500":
          description: Database error during tenant resolution.
